428 Commits

Author SHA1 Message Date
884e37fe1b Advanced to version 0.6.0. 2023-03-18 23:38:41 +08:00
cc6a73211e Updated the Sphinx documentation. 2023-03-18 23:38:19 +08:00
2299b86d0f Updated the translation. 2023-03-18 23:37:08 +08:00
6d293a1aac Added the JavaScript getInstances method to the SummaryEditor and AccountSelector classes, so that it is easier to deal with the case when the debit and credit versions are not both exist. 2023-03-18 23:36:38 +08:00
a2311aee24 Revised to prevent word wrapping in the button to choose the original entry in the summary editor. 2023-03-18 23:20:49 +08:00
5571c0d01f Renamed all the is_XXX_needed properties to is_need_XXX. For example, especially the is_offset_needed property to is_need_offset, to be clear and understandable. 2023-03-18 22:52:29 +08:00
98e1bad413 Renamed the test_not_needed test to test_not_need in the PaginationTestCase test case. 2023-03-18 22:43:53 +08:00
7ff52d99e6 Removed the unused is_offset_chooser_needed property from the AccountOption data model. 2023-03-18 22:40:56 +08:00
cc440a4110 Renamed the "_is_payable_needed" and "_is_receivable_needed" properties in the TransactionForm form to "_is_need_payable" and "_is_need_receivable", respectively, for readability and understandability. 2023-03-18 22:39:26 +08:00
f5149a0c37 Replaced the long parameter list with the JournalEntrySubForm instance in the onEdit method of the JavaScript JournalEntryEditor, to simplify the code. 2023-03-18 22:36:50 +08:00
ca928636fd Replaces the datasets with object attributes to store the currency code and entry type in the JavaScript OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
4a8297d594 Revised the documentation of the JavaScript OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
915e4408e1 Revised the JavaScript to initialize the OriginalEntrySelector instance in JournalEntryEditor, so that the journal entry editor holds the OriginalEntrySelector instance. It can find the OriginalEntrySelector instance without needing to invoke its static methods. Removed the redundant static methods from the OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
fd9eac06f6 Merged the code in the #initializeSummaryEditors method into the constructor in the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
403942dfc0 Added a missing semicolon in the saveOriginalEntry method of the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
35dc513760 Revised the #initializeSummaryEditors method of the JavaScript JournalEntryEditor class to construct the SummaryEditor instances with the entry type instead of the form element. Replaced the form element with the entry type in the constructor of the SummaryEditor class. Removed the unused accounting-summary-editor class and data-entry-type attributes from the template of the summary editor. 2023-03-18 22:11:45 +08:00
01861f0b6a Merged the code in the #initializeAccountSelectors method into the constructor in the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
8c10f1e96a Revised the ledger not to show the accumulated balance of the nominal accounts. The accumulated balance does not make sense for nominal accounts. 2023-03-18 22:11:45 +08:00
5f7fc0b8e8 Added the is_real pseudo property to the Account data model, and changed the is_nominal pseudo property to be the opposite of the is_real pseudo property. 2023-03-18 22:11:45 +08:00
700c179774 Applied the is_nominal pseudo property to the __get_brought_forward_entry method of the EntryCollector of the ledger. 2023-03-18 22:11:45 +08:00
cabe02f7d0 Added the is_nominal pseudo property to the Account data model. 2023-03-18 22:11:45 +08:00
5ceb9f2e83 Fixed and renamed the "__query_currency_period" method of the AccountCollector of the balance sheet to "__query_current_period". 2023-03-18 22:11:45 +08:00
fe1c7669b6 Added owner's equity accounts (base code starts with "3") when calculating the change of owner's equity in the specified period. 2023-03-18 22:11:45 +08:00
4eac10981f Added owner's equity (base code starts with "3") to the accounts that can take offset. 2023-03-18 22:11:44 +08:00
c869bccc04 Reordered the methods of the Account data model. 2023-03-18 22:11:44 +08:00
61c111db69 Revised the journal, the ledger, the income and expenses log, and the search result to show the last page first as the default upon pagination. 2023-03-18 22:11:44 +08:00
34f63c1cdf Renamed the "isOriginalEntry", "is-original-entry", "is_original_entry", and "isOriginalEntry()" methods and properties of journal entries to "isNeedOffset", "is-need-offset", "is_need_offset", and "isNeedOffset()", to be clear and understandable. 2023-03-18 22:11:44 +08:00
a643d9e811 Renamed the isAccountOffsetNeeded parameter to isAccountNeedOffset in the saveSummaryWithAccount method of the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
2239ddfad1 Revised the JavaScript to initialize the AccountSelector instances in JournalEntryEditor, so that the journal entry editor holds the AccountSelector instances. It can find the AccountSelector instance without needing to invoke its static methods. Removed the redundant static methods from the AccountSelector class. 2023-03-18 22:11:44 +08:00
12fbe36b9a Revised the JavaScript to initialize the SummaryEditor instances in JournalEntryEditor, so that the journal entry editor holds the SummaryEditor instances. It can find the SummaryEditor instance without needing to invoke its static methods. Removed the redundant static methods from the SummaryEditor class. 2023-03-18 22:11:44 +08:00
46e34bb89a Removed setting the redundant "entryType" dataset from the "onAddNew" and "onEdit" methods of the JournalEntryEditor class. It is not used anymore. 2023-03-18 22:11:44 +08:00
c9453d3023 Removed the redundant "entryType" parameter from the static "start" method of JavaScript AccountSelector. 2023-03-18 22:11:44 +08:00
fc766724c4 Removed the redundant "summary" parameter from the "#onOpen" and static "start" methods of JavaScript SummaryEditor. 2023-03-18 22:11:44 +08:00
38c394c0af Added the TransactionForm instance to the constructor of the JournalEntryEditor instance, so that the journal entry editor holds an instance of the transaction form, too. It does not need to find the transaction form all the way from the side property that may not be available. Retired the redundant getTransactionForm method from the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
67e2b06d37 Revised the JavaScript to initialize the JournalEntryEditor in TransactionForm, so that the transaction form holds the JournalEntryEditor instance. The DebitCreditSideSubForm and JournalEntrySubForm instances can find the JournalEntryEditor instance from the parent form, without needing to invoke its static methods. Removed the redundant static methods from the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
be10a8d99e Revised the coding style with the JavaScript arrow functions for the transaction form. 2023-03-18 22:11:44 +08:00
fbeec600b7 Replaced the long parameter list with the JournalEntryEditor instance in the save method of the JavaScript JournalEntrySubForm sub-form, to simplify the code. 2023-03-18 22:11:44 +08:00
1a54592d4c Added the amount attribute to the JavaScript JournalEntryEditor class to pass the amount to the JournalEntrySubForm without exposing the amount input element. 2023-03-18 22:11:44 +08:00
94a527caf2 Replaces the datasets with object attributes to store the column values in the JavaScript JournalEntryEditor class. 2023-03-18 22:10:28 +08:00
0a1bbbdd47 Replaced the isOriginalEntry dataset attribute with the isNeedOffset property in the JavaScript JournalEntryEditor. It does not make sense to store that information in the HTML. 2023-03-18 09:47:57 +08:00
82b63e4bd4 Revised the coding style in the JavaScript journal entry editor. 2023-03-18 04:02:48 +08:00
e1d1aff0c1 Simplified the code in the #resetDeleteCurrencyButtons method of the JavaScript TransactionForm form. 2023-03-18 03:55:55 +08:00
2e5f9ee01f Simplified the text data in the TestData clas in testlib_offset.py. 2023-03-18 03:41:51 +08:00
f901a0020f Revised the amount limitation tests in the OffsetTestCase test case, to be clear. 2023-03-18 03:38:07 +08:00
fc2be75c3b Changed the type of the amount property in the testing JournalEntryData data model from string to Decimal. 2023-03-18 03:21:47 +08:00
96c131940b Revised the date limitation tests in the OffsetTestCase test case, to be clear. 2023-03-18 03:08:08 +08:00
b9435a255b Added the "/.errors" route to the application in the "create_test_app" function in testlib.py, to make it easier to test. 2023-03-18 02:59:28 +08:00
56045f0faf Removed the unused __entry pseudo property from the JournalEntryForm form. 2023-03-17 22:52:38 +08:00
08d1e60238 Fixed the journal, the ledger, the income ane expenses log, and the search result to respect the transaction number before the debit/credit and the journal entry nuber. 2023-03-17 22:39:29 +08:00
d88b3ac770 Added to track the net balance and offset of the original entries. 2023-03-17 22:32:01 +08:00
40e329d37f Reordered the validators in the "accounting.transaction.forms.journal_entry" module. 2023-03-16 20:42:32 +08:00
23a0721d8d Added assert in the be function in the "accounting.utils.cast" module, to insure the correctness of the expression received. 2023-03-15 23:23:01 +08:00
2b2c665eb6 Replaced the if checks with assert in the IsBalanced validator of the currency sub-form of the transaction form, the NoOffsetNominalAccount validator of the account form, and the CodeUnique validator of the currency form. 2023-03-15 22:25:24 +08:00
954173a2c2 Removed the unused list-group-item-success class from style.css. 2023-03-15 01:43:49 +08:00
91e6dc6668 Removed an excess tailing blank line from the "accounting.currency.views" module. 2023-03-15 01:10:05 +08:00
e9d8a8fcd8 Added the "accounting.utils.cast" module to cast the things to the expected type in order to supress the warnings from PyCharm. 2023-03-15 01:09:59 +08:00
4c84686395 Removed an unused import from the "accounting" module. 2023-03-15 00:51:56 +08:00
61fd1849ed Removed the annotation future import from the "accounting.transaction.utils.account_option", "accounting.transaction.forms.journal_entry", and "accounting.transaction.forms.reorder" modules. 2023-03-14 21:51:19 +08:00
a67158f8f6 Moved the CodeUnique validator from an inner class of the CurrencyForm form to an independent class, and removed the annotation future import from the "accounting.currency.forms" module. 2023-03-14 21:48:11 +08:00
5c6bfd8b49 Revised the coding style of the NeedSomeCurrencies validator. 2023-03-14 21:42:02 +08:00
d9ecf51c6d Added the "create_test_app" function in testlib.py to replace "create_app" to prevent common mistakes. Added a get_csrf_token_view route to the application, and changed the get_csrf_token function to retrieve the CSRF token with the route without parsing the HTML for the CSRF token. 2023-03-14 21:28:35 +08:00
5d31eb9172 Removed the unnecessary future annotation import from the "accounting.transaction.forms.transaction" module. 2023-03-14 20:44:06 +08:00
fadce244c5 Revised the type hint and the coding style of the NeedSomeCurrencies validator. 2023-03-14 20:43:28 +08:00
cbe7c6ca6d Added dummy.js to .gitignore and MANIFEST.in for exclusion. 2023-03-14 17:03:22 +08:00
b03938fb2e Added test_temp.py to the exclusion in MANIFEST.in. 2023-03-14 17:03:21 +08:00
8061a23fdc Renamed the AbstractUserUtils class to UserUtilityInterface, and added the can_view and can_edit functions to the UserUtilityInterface interface. There is no need to separately supply two additional can_view and can_edit callbacks. 2023-03-14 17:03:18 +08:00
cd8a480cd0 Revised the documentation of the AbstractUserUtils class. 2023-03-14 17:03:16 +08:00
b8b87714eb Revised the documentation of the JavaScript summary editor. 2023-03-14 17:03:12 +08:00
bf2f96621d Revised so that when the account selector finds the codes from the form, the journal entry editor is used to find the form instead of messing-up with the TransactionForm class and its static method that was a shortcut to the private instance method of the same name. 2023-03-14 17:03:10 +08:00
2d771f04be Fixed so that saving the journal entry from the journal entry editor triggers updating the total of the debit or credit side, which in turn triggers validating the balance if it is on a transfer form. This fixed the problem that deleting a journal entry updates total but not re-validating the balance. 2023-03-14 17:03:06 +08:00
3a12472d4b Fixed the indent in the template of the account selector. 2023-03-14 17:03:01 +08:00
d5a686a5d8 Removed trailing blank spaces from the JavaScript summary editor. 2023-03-14 17:02:58 +08:00
690f89e29a Removed the unused accounting-debit-account-code and accounting-credit-account-code HTML classes. 2023-03-14 17:02:54 +08:00
82a6a53dc4 Revised the account selector to find the account codes from the form through the TransactionForm class, but not finding the codes by itself. 2023-03-14 17:02:52 +08:00
cdd31b1047 Added the missing documentation to the static initialize method of JavaScript TransactionForm class. 2023-03-14 17:02:50 +08:00
5bad949cfa Fixed the documentation of the entryType parameter in the constructor of the JavaScript DebitCreditSideSubForm sub-form. 2023-03-14 17:02:48 +08:00
3826646d06 Reordered the journal entry editor and put the summary first and the account later. 2023-03-14 17:02:46 +08:00
74071e8997 Removed the unused static validateAccount method from the JavaScript journal entry editor. 2023-03-14 17:02:43 +08:00
3ce34803f3 Moved the journal entry editor from the transaction-form.js to a new independent JavaScript file journal-entry-editor.js. 2023-03-14 17:02:41 +08:00
232f73172f Removed the prefix from the journal entry sub-form. 2023-03-14 17:02:39 +08:00
ff1bb7142b Removed the unused data-prefix attribute from the currency sub-forms of the transaction form. 2023-03-14 17:02:37 +08:00
7155bf635a Removed the data-entry-type attribute from the journal entry editor form. The entry type is passed by the object. There is no need to store this information in the HTML anymore. 2023-03-14 17:02:35 +08:00
c306ff8009 Revised the JavaScript journal entry editor and account selector so that the account selector work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 17:02:32 +08:00
b344abce06 Added the #prefix property to the journal entry editor to simplify the consistency. 2023-03-14 17:02:29 +08:00
b3c666c872 Fixed the addJournalEntry method of the DebitCreditSideSubForm sub-form to re-validate the whole side after a new journal entry is added. 2023-03-14 17:02:14 +08:00
6a671cac84 Revised the JavaScript journal entry editor and summary editor so that the summary editor work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 16:59:54 +08:00
fe87c3a7de Fixed the documentation of the #side property of the JavaScript JournalEntryEditor class. 2023-03-14 16:59:51 +08:00
2013f8cbd9 Removed the initializeNewJournalEntry method from the JavaScript SummaryEditor. It does not do meaningful things at all. 2023-03-14 16:59:49 +08:00
2325842471 Fixed the documentation of the JavaScript for the transaction form. 2023-03-14 16:59:48 +08:00
c80e58b049 Renamed the journal entry form to journal entry editor, to be clear. 2023-03-14 16:59:46 +08:00
be0ae5eba4 Replaced the function-based JavaScript with the object-oriented TransactionForm, CurrencySubForm, DebitCreditSideSubForm, JournalEntrySubForm, and JournalEntryForm classes for the transaction form. 2023-03-14 16:59:36 +08:00
2b84f64554 Replaced the function-based JavaScript with the object-oriented AccountForm class for the currency form. 2023-03-12 22:15:56 +08:00
0a658a76e8 Replaced the function-based JavaScript with the object-oriented AccountForm class for the account form. 2023-03-12 22:15:54 +08:00
50dc79d865 Added the missing is-invalid class on errors to the currency field in the currency sub-forms of the transaction form. 2023-03-12 16:51:25 +08:00
8e5377a416 Replaced the payable account with the petty-cash account in the SummeryEditorTestCase test case. 2023-03-12 01:34:47 +08:00
4299fd6fbd Revised the code in the JavaScript initializeBaseAccountSelector function in the account form. 2023-03-12 01:34:45 +08:00
1d6a53f7cd Revised the account form so that the if-offset-needed option is only available for real accounts. 2023-03-12 01:34:42 +08:00
bb2993b0c0 Reordered the code in the "accounting.transaction.forms.journal_entry" module. 2023-03-11 20:36:38 +08:00
f6946c1165 Revised the IsBalanced validator so that it no longer need the __future__ annotation. 2023-03-11 19:10:47 +08:00
8e219d8006 Fixed the type hint of the form parameter in the NeedSomeJournalEntries validator. 2023-03-11 19:10:44 +08:00
53565eb9e6 Changed the IsBalanced validator from an inner class inside the TransferCurrencyForm form to an independent class. 2023-03-11 19:10:42 +08:00
965e78d8ad Revised the rule for the accounts that need offset in the accounting-init-accounts console command. 2023-03-11 17:15:08 +08:00
74b81d3e23 Renamed the offset_original_id column to original_entry_id, and the offset_original relationship to original_entry in the JournalEntry data model. 2023-03-11 16:34:30 +08:00
a0fba6387f Added the order to the search report. 2023-03-11 16:34:30 +08:00
d28bdf2064 Revised the parameter order in the template of the currency sub-form of the transaction form. 2023-03-11 16:34:29 +08:00
edf0c00e34 Shortened the names of the #filterAccountOptions, #getAccountCodeUsedInForm, and #shouldAccountOptionShow methods to #filterOptions, #getCodesUsedInForm, and #shouldOptionShow, respectively, in the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
107d161379 Removed a debug output from the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
f2c184f769 Rewrote the JavaScript AccountSelector to store the page elements in the object. 2023-03-11 16:34:28 +08:00
b45986ecfc Fixed the parameter type for the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
a2c2452ec5 Added a missing blank line to the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
5194258b48 Removed the redundant #init method from the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
3fe7eb41ac Removed the unused "__in_use_account_id" property from the TransactionForm form. 2023-03-11 16:34:28 +08:00
7fb9e2f0a1 Added missing documentation to the OptionLink data model in the "accounting.report.utils.option_link" module. 2023-03-11 16:34:28 +08:00
1d443f7b76 Renamed the "accounting.transaction.form" module to "accounting.transaction.forms". It only contains forms now. 2023-03-11 16:34:28 +08:00
6ad4fba9cd Moved the "accounting.transaction.operators", "accounting.transaction.summary_editor" and "accounting.transaction.form.account_option" modules into the "accounting.transaction.utils" module. 2023-03-11 16:34:28 +08:00
3dda6531b5 Split the "accounting.transaction.forms" module into various submodules in the "accounting.transaction.form" module. 2023-03-11 16:33:51 +08:00
4d11517e21 Advanced to version 0.5.0. 2023-03-10 08:36:22 +08:00
308e4ac69d Updated the Sphinx documentation. 2023-03-10 08:36:08 +08:00
de09e1498b Added the __get_default_page_uri() function to the "accounting.transaction.views" module to simplify the code. 2023-03-10 08:34:44 +08:00
c26c4686c5 Renamed the "original_id" column to "offset_original_id", and the "original" and "offset" relationships to "offset_original" and "offsets", respectively, in the JournalEntry data model. 2023-03-10 08:25:38 +08:00
c95f4fcc47 Added the __str property and changed the query_values property from a pseudo property to a real property in the AccountOption data model, so that it does not need to hold the account object forever. 2023-03-09 22:50:18 +08:00
71af74fc8a Added documentation to the properties of the AccountOption data model. 2023-03-09 22:47:46 +08:00
56e972c371 Fixed so that the download buttons on the report pages are disabled when there is no data. 2023-03-09 22:29:44 +08:00
7feb6da062 Fixed the JavaScript period chooser error when there is no data. 2023-03-09 22:25:26 +08:00
af71874f9d Fixed an error checking if there is any data in the PeriodChooser utility. 2023-03-09 22:20:24 +08:00
3fa8818a27 Added the is_check_as parameter to the get_txn_op function so that the "as" query parameter is not checked when showing the transaction detail. 2023-03-09 22:14:22 +08:00
be46d8aa14 Renamed the default_io_account_code and default_io_account functions to default_ie_account_code and default_ie_account, respectively. That was a mistake. 2023-03-09 20:59:21 +08:00
20f55058ac Shortened the name of the "accounting.report.utils.income_expenses_account" module to "accounting.report.utils.ie_account". 2023-03-09 20:59:21 +08:00
e9d1a53e03 Shortened the name of the "accounting.report.utils.income_expenses_account" module to "accounting.report.utils.ie_account". 2023-03-09 20:59:21 +08:00
38141759fd Removed an excess blank line in the "accounting.report.view" module. 2023-03-09 20:59:20 +08:00
7fb3e3bc2c Shortened the names of the views of the reports. 2023-03-09 20:59:20 +08:00
05ac5158f8 Added the default report view as the income and expenses log with the default currency, default account and default period. Changed the previous default journal links to the current default. 2023-03-09 20:59:09 +08:00
ec257a4b57 Renamed the "accounting.report.period.periods" module to "accounting.report.period.shortcuts", to be clear. 2023-03-09 20:13:15 +08:00
5ebb89a6d5 Moved the month_end utility from the "accounting.report.period.period" module to the new "accounting.report.period.month_end" module. 2023-03-09 19:56:06 +08:00
900d60d1ae Moved the shortcut named periods from the "accounting.report.period.period" module to the "accounting.report.period.periods" module. 2023-03-09 19:44:53 +08:00
bc792c145f Replaced the Period.get_instance method with the get_period function in the "accounting.report.period.parser" module. Changed the parse_spec function in the "accounting.report.period.parser" to private. 2023-03-09 19:40:34 +08:00
4432484acd Replaced the PeriodSpecification object-based utility with the get_spec function-based utility, for simplicity. 2023-03-09 19:30:36 +08:00
7ad3f9e0cb Replaced the PeriodDescription object-based utility with the get_desc function-based utility, for simplicity. 2023-03-09 19:25:43 +08:00
060a52f7a2 Moved the period specification parser from the "accounting.report.period.period" module to the "accounting.report.period.parser" module. 2023-03-09 19:10:21 +08:00
c17430d211 Renamed the "accounting.report.period.period_chooser" module to "accounting.report.period.chooser", for simplicity. 2023-03-09 19:07:58 +08:00
8fd99bb617 Simplified the import of the datetime module in the "accounting.report.period.period" module. 2023-03-09 19:05:27 +08:00
ce388eb6c8 Moved the PeriodSpecification and PeriodDescription utilities from the "accounting.report.period.period" module to the "accounting.report.period.specification" and "accounting.report.period.description" modules, respectively. 2023-03-09 18:57:29 +08:00
1850f9787e Moved the period and period chooser to the "accounting.report.period" module. 2023-03-09 18:30:41 +08:00
c6d55fad1c Renamed the "accounting.report.utils.period_choosers" module to "accounting.report.utils.period_chooser", because there is only period chooser now. 2023-03-09 18:14:20 +08:00
0c647d8f21 Moved the "accounting.reports.period" and "accounting.reports.income_expense_account" utility modules into the "accounting.reports.utils" module. 2023-03-09 18:13:18 +08:00
5d1f87582e Moved the "accounting.report.reports.utils" module to "accounting.report.utils". It does not make sense to have a wierd and long module name just to make the import pretty. 2023-03-09 18:09:08 +08:00
ef086b3f81 Revised to simplify the YearPeriod period. 2023-03-09 18:03:02 +08:00
b4be1db712 Revised the imports in the "accounting.report.reports.utils.period_chooser" module. 2023-03-09 18:00:38 +08:00
5d44ebdfd8 Revised the properties of the Today, Yesterday, and AllTime periods. 2023-03-09 17:58:49 +08:00
9859604c81 Revised the documentation of the _set_properties method of the Period utility. 2023-03-09 17:56:27 +08:00
d31e495f6b Added the AllTime class as a named period. 2023-03-09 17:49:55 +08:00
7c4102be44 Fixed the documentation of the "ReportType.SEARCH" enum item. 2023-03-09 17:49:05 +08:00
1fd50e23d9 Changed the PeriodChooser utility from abstract to real, and replaced the various trivial subclasses with the get_url callable as the parameter. 2023-03-09 17:43:21 +08:00
9635448f18 Added the missing documentation to the sections property of the PageParams data model in the income statement report. 2023-03-09 17:36:33 +08:00
e7f1ca332e Revised the imports in the modules of ledger, income and expenses log, trial balance, and income statement. 2023-03-09 17:32:22 +08:00
3d2e40865e Revised the PeriodChooser utility to find the start of the data by itself. It can do that. It's child classes are all doing the same thing. There is no need to do that in its child classes. 2023-03-09 17:20:52 +08:00
5132141c68 Renamed the "is_pay_off_needed" column of the Account data model to "is_offset_needed", and the "pay_off_target_id" column of the JournalEntry data model to "original_id". 2023-03-09 17:16:05 +08:00
e37f6792c9 Replaced aria-label with aria-labelled-by in the search modal of the report, for simplicity. 2023-03-09 16:42:13 +08:00
e6b1136a14 Fixed so that the brought-forward row is not added for norminal accounts in the ledger. 2023-03-09 16:25:59 +08:00
d7bc01ccb4 Updated the Sphinx documentation. 2023-03-09 14:38:45 +08:00
27beff3f8f Renamed the accounting-search and accounting-search-label HTML ID to accounting-toolbar-search and accounting-toolbar-search-label, respectively. 2023-03-09 14:37:03 +08:00
c6c545b99f Removed the unused accounting-search-form, accounting-search-desktop-form, accounting-search-input, and accounting-search-label classes. 2023-03-09 14:37:02 +08:00
6d5a2fae6a Applied the accounting-toolbar class to the base account list, account list, and currency list. 2023-03-09 14:37:02 +08:00
8819eabcd0 Replaced the separated toolbar for the desktop and mobile screen with the accounting-toolbar class that acts differently on different screen sizes. 2023-03-09 14:37:01 +08:00
3582d960ca Replaced the toolbar button group with individual buttons on the reports. 2023-03-09 14:37:01 +08:00
02e10a301a Removed the unused custom "btn-actions" class from the templates. 2023-03-09 14:36:59 +08:00
f0187434d2 Fixed the error from the month chooser in the period chooser when the current period has no start as the default month. 2023-03-09 14:36:59 +08:00
34af52e3c3 Revised the __add_owner_s_equity method of the AccountCollector of the balance sheet to receive the period instead of the URL, and does its job when there is an amount, so that the URL is build only when there is an amount. 2023-03-09 14:36:58 +08:00
965df82c1c Fixed the logic in the __add_owner_s_equity method of the AccountCollector of the balance sheet, that when there is an existing balance, only set the URL when there's amount to be added. 2023-03-09 14:36:57 +08:00
df53f06094 Renamed the "accounting.report.reports.utils.get_url" module to "accounting.report.reports.utils.urls", and shortened the names of the utilities, for readability. 2023-03-09 14:36:56 +08:00
140d3c6010 Added the get_balance_sheet_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:55 +08:00
a65dccac92 Added the get_journal_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:55 +08:00
740e1cfac1 Added the get_trial_balance_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:54 +08:00
b62f31d385 Added the get_income_statement_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:54 +08:00
1c740b9bbc Added the get_income_expenses_url utility to replace the common codes to retrieve the URL of an income and expenses log. 2023-03-09 14:36:53 +08:00
380256eda7 Revised the imports in the reports. 2023-03-09 14:36:52 +08:00
74b695c089 Added the get_ledger_url utility to replace the common codes to retrieve the URL of a ledger. 2023-03-09 14:36:50 +08:00
6d1e705e4b Revised the documentation of the "accounting.report.reports.utils.csv_export" module. 2023-03-09 14:36:49 +08:00
8abe20dba5 Revised the __set_data method of the trial balance and the __query_balances of the income statement for consistency. 2023-03-09 14:36:48 +08:00
ed7a8ac0fd Added the __query_balance method to the AccountCollector of balance sheet to simplify the queris in the __query_accumulated and __query_currency_period methods. 2023-03-09 14:36:48 +08:00
74eee034d0 Replaced the "transaction" property with the "url" property in the ReportEntry model of the income and expenses log, so that the report entry does not need to keep the transaction object. 2023-03-09 14:36:47 +08:00
d19d23fe37 Replaced the "transaction" property with the "url" property in the ReportEntry model of ledger, so that the report entry does not need to keep the transaction object. 2023-03-09 14:36:46 +08:00
4ce577d7d8 Removed the unused entry property from the ReportEntry model of the income and expenses log. 2023-03-09 14:36:45 +08:00
a340fad109 Removed the unused entry and account properties from the ReportEntry model of the ledger. 2023-03-09 14:36:45 +08:00
555ad388bc Added the debit and credit pseudo properties to the JournalEntry data model, and retired the redundant ReportEntry model from the "accounting.report.reports.journal" module. 2023-03-09 14:36:44 +08:00
2f27ad5bef Replaced querying the transactions later with the "selectinload" query option in the ledger. Retired the unused populate_entries function from the "accounting.report.reports.ledger" module. 2023-03-09 14:36:43 +08:00
c6487bf9d4 Replaced querying the transactions later with the "selectinload" query option in the income and expenses log. Retired the unused populate_entries function from the "accounting.report.reports.income_expenses" module. 2023-03-09 14:36:42 +08:00
ff3dd28cd7 Replaced querying the transactions later with the "selectinload" query option in the journal and search reports. Retired the unused populate_entries function from the "accounting.report.reports.journal" module. 2023-03-09 14:36:42 +08:00
a14ffa93ed Replaced querying the currencies later with the "selectinload" query option in the journal and search reports. 2023-03-09 14:36:41 +08:00
672fcbcbdf Replaced querying the accounts later with the "selectinload" query option in the income and expenses log. 2023-03-09 14:36:41 +08:00
cb4258dd6d Removed the unused "is_total" property from the ReportEntry class of the journal. 2023-03-09 14:36:40 +08:00
6fc21f82af Changed the entry parameter of the ReportEntry class in journal to be non-optional. There is no optional entry in its actual use. 2023-03-09 14:36:40 +08:00
13e3ef5875 Replaced querying the accounts later with the "selectinload" query option in the journal and search reports, and restored the lazy setting in the account relationship of the JournalEntry data model. 2023-03-09 14:36:40 +08:00
21b3320e66 Revised the add-txn-material-fab.html template to simplify the code to include it. 2023-03-09 14:36:39 +08:00
5c47e63ae3 Moved the add-txn-material-fab.html template from the accounting/include directory to the accounting/report/include directory, because it is only used in the reports now. 2023-03-09 14:36:39 +08:00
f59378002e Removed the list_transactions view that is not used now. 2023-03-09 14:36:38 +08:00
531e90e8ad Revised the imports in the "accounting.transaction.view" module. 2023-03-09 14:36:38 +08:00
8fc33131dd Changed the transaction operation to return to the default journal instead of the transaction list. The transaction list is to be removed. There is no link to the transaction list at all, and it's layout is undecided. 2023-03-09 14:36:38 +08:00
62716eb545 Fixed the report chooser to set the current report when the current report is the search page. 2023-03-09 14:36:36 +08:00
14d5d1e8d6 Renamed the action-buttons.html template to toolbar-buttons.html. 2023-03-09 14:36:35 +08:00
4306ed739f Added the is_search property to the report chooser to highlight the search when it is on the search page. 2023-03-09 14:36:34 +08:00
1f87bc00e8 Removed the excess "with_type" from the success redirection of the update_transaction view. 2023-03-09 14:36:33 +08:00
ff9ff4bdcf Removed the excess "with_type" from the success redirection of the delete_transaction view. 2023-03-09 14:36:33 +08:00
578233d66d Renamed the sort_accounts view to sort_transactions in the "accounting.transaction.views" module, and fixed its url endpoints on success. 2023-03-09 14:36:32 +08:00
5e7f790f87 Moved the __get_csv_rows method of the Journal report to the get_csv_rows function, and revised the Search report to use it, because both of their __get_csv_rows methods are identical. 2023-03-09 14:36:32 +08:00
d64f354ee0 Added the DATE_SPEC_RE constant to simplify the regular expression matching in the _parse_period_spec function. 2023-03-09 14:36:32 +08:00
ba3d8c6d4e Removed a redundant test in the _parse_period_spec function in the "accounting.report.period" module. 2023-03-09 14:36:31 +08:00
4f7f87b10d Removed an unused import from the "accounting.report.reports.utils" module. 2023-03-09 14:36:30 +08:00
4273f99644 Fixed the regular expression to match the extra note in the summary for security, as suggested by SonarQube. 2023-03-09 14:36:30 +08:00
ffe834bedd Added the DATE_REQUIRED constant to the "accounting.transaction.forms" module as the common date field validator. 2023-03-09 14:36:29 +08:00
e448e009c9 Simplified the declaration of the "available_years" property in the PeriodChooser utility. 2023-03-09 14:36:29 +08:00
b6802c51bb Removed an excess blank line in the __get_since_desc method of the PeriodDescription utility. 2023-03-09 14:36:29 +08:00
2515c1ea1f Added the __get_since_spec and __get_until_spec methods to simplify the __get_spec method in the PeriodSpecification utility. 2023-03-09 14:36:28 +08:00
0ef6409f75 Revised the documentation of the PeriodDescription utility. 2023-03-09 14:36:28 +08:00
ed18b81ad8 Moved the code to compose the period specification from the Period utility to the PeriodSpecification utility, to simplify the code. 2023-03-09 14:36:27 +08:00
b46cec6fab Updated the translation. 2023-03-09 14:36:27 +08:00
6c122666a0 Revised to simplify the PeriodDescription utility. 2023-03-09 14:36:27 +08:00
7ddc9ececf Added the __format_day method to the PeriodDescription utility to simplify the code. 2023-03-09 14:36:26 +08:00
4eebbd9692 Moved the code to compose the period description from the Period utility to the PeriodDescription utility, to simplify the code. 2023-03-09 14:36:25 +08:00
338b49c965 Added the __get_since_desc and __get_until_desc methods to simplify the __get_desc method in the Period utility. 2023-03-09 14:36:25 +08:00
f438f97571 Revised the styles of the f-strings in the Period utility. 2023-03-09 14:36:24 +08:00
9b273115a0 Removed the empty _set_properties method override from the YearPeriod period. 2023-03-09 14:36:24 +08:00
58d1add810 Added type hints to the CASH_CODE, ACCUMULATED_CHANGE_CODE, and NET_CHANGE_CODE constants. 2023-03-09 14:36:23 +08:00
c189615ca4 Renamed the CASH, ACCUMULATED_CHANGE, and NET_CHANGE constants to CASH_CODE, ACCUMULATED_CHANGE_CODE, and NET_CHANGE_CODE, respectively, to avoid confusion. 2023-03-09 14:36:23 +08:00
5687852dfb Added the _get_currency_options method to the BasePageParams class, and applied it to the currency_options pseudo property of the PageParams classes of the ledger, income and expenses log, trial balance, income statement, and balance sheet reports. 2023-03-09 14:36:22 +08:00
d74c62dbb7 Removed excess property documentation from the Journal and Search classes. 2023-03-09 14:36:22 +08:00
987e98ebc0 Moved the code to collect the report entries to the EntryCollector class in the Search report. 2023-03-09 14:36:21 +08:00
7083f22577 Revised the documentation in the page parameters and the report in the ledger and income and expenses log. 2023-03-09 14:36:21 +08:00
7b10eb68bc Revised the documentation of the EntryCollector class in the ledger and income and expenses log. 2023-03-09 14:36:20 +08:00
f277010991 Renamed the TrialBalanceTotal class to Total, to be short and clear. 2023-03-09 14:36:19 +08:00
729a7fd107 Renamed the TrialBalanceAccount, IncomeStatementAccount, and BalanceSheetAccount classes to ReportAccount, to be short and clear. 2023-03-09 14:36:19 +08:00
c8230c949d Renamed the Entry class to ReportEntry in the journal, ledger, income and expenses log, and search result, to be clear without confusion. 2023-03-09 14:36:18 +08:00
3c98960efe Replaced the Entry CSVRow, and populate_entries in the "accounting.report.reports.search" module with those in the journal module, because their contents are identical. 2023-03-09 14:36:18 +08:00
c5d0d91a7d Renamed the _populate_entries functions to populate_entries in journal, ledger, income and expenses log, and search result, changing them from protected to public so that they can be reused. 2023-03-09 14:36:17 +08:00
fb06e9db44 Shortened the names of the BalanceSheetSubsection and BalanceSheetSubsection classes to Section and Subsection, respectively. 2023-03-09 14:36:17 +08:00
d47e2e231b Shortened the names of the IncomeStatementSection, IncomeStatementSubsection, and IncomeStatementAccumulatedTotal classes to Section, Subsection, and AccumulatedTotal, respectively. 2023-03-09 14:36:17 +08:00
cb89f34455 Renamed the "PageParams" class to "BasePageParams", and renamed its module from "accounting.report.reports.utils.page_params" to "accounting.report.reports.utils.base_page_params". Renamed all its subclasses to PageParams, to shorten their names and make code more readable. 2023-03-09 14:36:17 +08:00
11ab4a4ba6 Revised the documentation of the CSV rows for the reports. 2023-03-09 14:36:16 +08:00
5dc8387ad9 Fixed the incorrect account in the __add_current_period method of the AccountCollector class in the "accounting.report.reports.balance_sheet" module. 2023-03-09 14:36:16 +08:00
26b70bb625 Fixed the logic for all-time in the period_spec function in the "accounting.report.reports.utils.csv_export" module. 2023-03-09 14:36:15 +08:00
f30a96d7e9 Simplified the logic in the period_spec method in the "accounting.report.reports.utils.csv_export" module. 2023-03-09 14:36:15 +08:00
a1627b7fbf Revised to use a simpler way to run the class methods in the __get_desc method of the Period utility, to prevent confusion with SonarQube. 2023-03-09 14:36:14 +08:00
7c3b8c8f44 Revised to store the newly-constructed period chooser and month chooser in variables to prevent SonarQube from complaining. 2023-03-09 14:36:13 +08:00
b19f4fa939 Added "use strict"; to all the JavaScript files. 2023-03-09 14:36:13 +08:00
41c3e06ce4 Removed the period chooser JavaScript from the search result page. 2023-03-09 14:36:12 +08:00
8a3df7a689 Revised the search report to match the amount when the query keyword is a number, instead of matching the amount as a text string. For example, "0150.00" matches 150, while "50" does not match 150. 2023-03-09 14:36:11 +08:00
196a115c99 Revised the coding style in the __get_transaction_condition method of the Search report. 2023-03-09 14:36:11 +08:00
005f9083aa Revised the constructor of the IncomeExpensesAccount pseudo account. 2023-03-09 14:36:10 +08:00
12dbae56c4 Revised the f-strings in the "accounting.models" module. 2023-03-09 14:36:10 +08:00
a98723c57b Removed an unused import from the "accounting.utils.pagination" module. 2023-03-09 14:36:10 +08:00
d5bd3b8383 Fixed an HTML error in the template of the trial balance. 2023-03-09 14:36:09 +08:00
617dd29f23 Added the period_spec function to be used to compose the download file name, to replace the spec property of the Period utility. 2023-03-09 14:36:08 +08:00
b0a4a735f3 Added the is_a_month property to the Period utility. 2023-03-09 14:36:08 +08:00
41770e38b8 Updated the translation. 2023-03-09 14:36:08 +08:00
d8a6614543 Fixed the text for the account used in the titles of the ledger and the income and expenses log. 2023-03-09 14:36:07 +08:00
8d76b5130e Fixed the localization function used in the titles of the reports. 2023-03-09 14:36:07 +08:00
43fc4b9b8d Renamed "total revenue" to "total operating revenue". 2023-03-09 14:36:07 +08:00
3ed8d7f1d2 Removed the now-unused table-row-link.js. It is replaced by the grid display. 2023-03-09 14:36:06 +08:00
ea7c194d7e Revised the period-chooser.html template to simplify the code to include it. 2023-03-09 14:36:06 +08:00
041a905fc0 Added font-awesome icons to the report chooser. 2023-03-09 14:36:05 +08:00
10d1be8bd1 Added the action-buttons.html template and retired the report-chooser.html and currency-chooser.html templates, as the template for the common action buttons. 2023-03-09 14:36:03 +08:00
6e1d35eda4 Revised the report-chooser.html template to simplify the reports. 2023-03-09 14:36:03 +08:00
52b5151fe0 Added the currency-chooser.html template to simplify the templates of the report. 2023-03-09 14:36:02 +08:00
f9fc033de6 Removed the unused RECEIVABLE, PAYABLE, and BROUGHT_FORWARD constants and the unused receivable(), payable(), brought_forward(), and net_change() shortcut methods from the Account data model. 2023-03-09 14:36:02 +08:00
116d00a557 Replaced the hard-coded cash account codes with the ACCUMULATED_CHANGE and NET_CHANGE constants and the accumulated_change() method of the Account data model. 2023-03-09 14:36:01 +08:00
329e3d5362 Replaced the hard-coded cash account codes with the CASH constant and the cash() method of the Account data model. 2023-03-09 14:36:00 +08:00
47e8944f06 Changed the constants of the common account codes in the Account data model from private to public. 2023-03-09 14:36:00 +08:00
e7c43ae390 Revised the documentation to use the term "income and expenses log" instead of "income and expenses", for consistency. 2023-03-09 14:36:00 +08:00
b8b51b34d3 Added the income-expenses-row-desktop.html and ledger-row-desktop.html templates to simplify templates of the income and expenses log and ledger. 2023-03-09 14:36:00 +08:00
d083036719 Renamed the income-expenses-mobile-row.html and ledger-mobile-row.html templates to income-expenses-row-mobile.html and ledger-row-mobile.html, respectively. 2023-03-09 14:35:59 +08:00
7fe81c710b Added the balance-sheet-section.html template to simplify the template of balance sheet. 2023-03-09 14:35:59 +08:00
9993f65627 Added the search to the accounting data. 2023-03-09 06:37:31 +08:00
fe01d5418d Fixed to limit the width of the search box in the currency list, base account list, account list, and transaction list. 2023-03-09 06:37:31 +08:00
2f7b9932a0 Added the base report class to ensure that the reports can both be shown on the page and downloaded as CSV. 2023-03-09 06:37:28 +08:00
1eed16b732 Added the pseudo account for the income and expenses log to query the income and expenses log of the current assets and liabilities. 2023-03-09 06:37:28 +08:00
ede1160943 Fixed the ledger and the income and expenses log not to show the total entry when there is actually no data. 2023-03-09 06:37:27 +08:00
3814f0cb18 Added the missing accounting_format_amount filter to the total of the ledger. 2023-03-09 06:37:27 +08:00
24315b8203 Fixed the styles of the negative numbers in the reports with red and braced absolute values. 2023-03-09 06:37:26 +08:00
3c200d0dc6 Fixed the sign of the amount in income statement. 2023-03-09 06:37:26 +08:00
9f1e724875 Added the "accounting.report.reports.csv_export" module to handle the CSV export in one place. 2023-03-09 06:37:26 +08:00
f838e7f893 Moved the utilities that are only for the report generators from the "accounting.report" module to the "accounting.report.reports.utils" module. 2023-03-09 06:37:25 +08:00
edb893ecd3 Replaced the report generators with a separated module for each report, to work with the diversity of the report formats without messing-up one another. 2023-03-09 06:37:23 +08:00
436a4c367f Added the balance sheet. 2023-03-09 06:37:22 +08:00
1813ce0cfa Removed the unused and empty __get_category method from the IncomeStatement report. 2023-03-09 06:37:22 +08:00
7683347997 Removed a non-existing parameter from the documentation of the constructor of the IncomeStatementParams class. 2023-03-09 06:37:22 +08:00
46ffc7a73d Changed the display style of the rows in the income statement from grid to flex, to simplify the layout. 2023-03-09 06:37:21 +08:00
e0a807d625 Fixed the style of the indent and total line of the income statement. 2023-03-09 06:37:20 +08:00
dffcf6d2ce Removed the unused is_account pseudo property in the IncomeStatementRow row. 2023-03-09 06:37:19 +08:00
84d239e4b1 Added the income statement. 2023-03-09 06:37:19 +08:00
fcefc64117 Removed an excess closing </div> in the template of the trial balance. 2023-03-09 06:37:18 +08:00
81fbb380b4 Renamed the variable select_trial_balance to select_balances in the __query_balances method of the TrialBalance report. 2023-03-09 06:37:17 +08:00
d7ac8a3dcf Fixed the documentation of the TrialBalanceRow class. 2023-03-09 06:37:17 +08:00
bcd3418e2c Fixed the documentation in the constructor of the trail balance. 2023-03-09 06:37:16 +08:00
ef9e5cb5b3 Split the report parameters from the report class so that it works better with both CSV export and HTML templates. 2023-03-09 06:37:16 +08:00
e797cfeb8c Simplified the logic to test the total row in the ledger and income and expenses. 2023-03-09 06:37:16 +08:00
22bae7f766 Replaced the <ul></ul> list with CSS "display: grid" for the trial balance, to allow using <a></a> as the table row. 2023-03-09 06:37:15 +08:00
aa669e9f53 Replaced tables with CSS "display: grid" for the journal, ledger, and income and expenses, to allow using <a></a> as the table row. 2023-03-09 06:37:14 +08:00
898a1af7b5 Revised the separation lines in the table headers and footers of the ledger tables. 2023-03-09 06:37:13 +08:00
f762bcf48f Replaced the duplicated "accounting-transaction-card" and "accounting-report-card" CSS classes with the "accounting-sheet" class, for simplicity. 2023-03-09 06:37:12 +08:00
5d4effa360 Added the "debit" and "credit" properties to, and removed the "is_debit" property from the JournalRow row, to reduce the amount of logic in the template of the journal. 2023-03-09 06:37:11 +08:00
dd05478bf3 Simplified the syntax to retrieve query arguments in the templates, and reduced the amount of logic in the templates. 2023-03-09 06:37:11 +08:00
9450915404 Added the "default" filter to reduce the amount of logic in the templates, which differs from the Jinja2 "default" filter in that it looks for None instead of undefined values. 2023-03-09 06:37:10 +08:00
8d126e183f Changed the date field of the transaction forms to set the default value in the view, but not the form, so that the default value is not set when it did not receive a value. 2023-03-09 06:37:10 +08:00
bfb08cf5fc Changed the format_amount_input filter to accept None, and return an empty string if the value is None. 2023-03-09 06:37:09 +08:00
a7bcf4b5c1 Changed the format_amount template filter to return None when the value is None. 2023-03-09 06:37:08 +08:00
cd49ca44b1 Fixed to avoid getting the income and expenses with accounts that are not current assets and liabilities when switching from the ledger. 2023-03-09 06:37:07 +08:00
734362396f Adding the missing currency when constructing the report chooser in the trial balance. 2023-03-09 06:37:06 +08:00
88147bea66 Revised the currency in the titles and options of the ledger, income and expenses, and trial balance. 2023-03-09 06:37:05 +08:00
cca43c68a6 Added trial balance. 2023-03-09 06:37:05 +08:00
480e2d2d8f Simplified the invocation of the super class constructor in the subclasses of Period. 2023-03-09 06:37:05 +08:00
be100ce7ec Simplified the constructors of the period choosers. 2023-03-09 06:37:04 +08:00
eca91d32ed Fixed the documentation of the IncomeExpensesPeriodChooser class. 2023-03-09 06:37:04 +08:00
1f95212494 Revised to use the title case in the CSV output of the journal and income and expenses. 2023-03-09 06:37:03 +08:00
0173104c84 Revised the currency field in the CSV output of the journal. 2023-03-09 06:37:03 +08:00
6e33fa775d Revised the __query_entries method of the IncomeExpenses report to be clear. 2023-03-09 06:37:02 +08:00
e244ff70e6 Simplified the SQL query in the currency_options and account_options pseudo properties in the Ledger report and the account_options pseudo property in the IncomeExpenses report. 2023-03-09 06:37:01 +08:00
ace782a26b Replaced "sa.select" with "sa.Select" in the account_options pseudo property of the IncomeExpenses report. 2023-03-09 06:37:01 +08:00
90289a0db2 Fixed the account options to list only the current assets and liabilities for the income and expenses. 2023-03-09 06:37:01 +08:00
7e7e1a2844 Revised so that the amounts won't wrap in the income and expenses. 2023-03-09 06:37:01 +08:00
ddd028736c Revised the balance in the mobile view of the income and expenses. 2023-03-09 06:37:01 +08:00
e1d35a64da Revised the account shown in the journal. 2023-03-09 06:37:01 +08:00
39807ef480 Added the income and expenses. 2023-03-09 06:37:00 +08:00
39723b1299 Removed the lazy setting from the account relationship of the JournalEntry data model. It results in problems in the income and expense report. 2023-03-09 06:36:25 +08:00
8cd004bede Revised the documentation of the ledger. 2023-03-05 18:16:43 +08:00
4f112dd386 Revised the documentation of the report row classes. 2023-03-05 18:14:32 +08:00
b806b1ed1f Added note to the CSV output of ledgers. 2023-03-05 17:55:47 +08:00
1d0a79e33c Changed the CSV field name to be title-cased. 2023-03-05 17:55:47 +08:00
d4a690ebbc Removed the text of the action buttons of currency and account filters for the small screens, to fit in the screen. 2023-03-05 17:55:46 +08:00
68687897f3 Changed the icons for the reports and accounts to be more accessible. 2023-03-05 17:55:46 +08:00
a7250fd9bf Replaced transactions with reports in the navigation menu. 2023-03-05 17:55:46 +08:00
eabe80b790 Added ledger. 2023-03-05 17:55:38 +08:00
fe77f87110 Fixed a regular expression in the _parse_period_spec function of the period utility. 2023-03-05 14:24:51 +08:00
32c27d7c07 Fixed an error finding the end of month in the __get_month_spec method of the Period utility. 2023-03-05 12:06:16 +08:00
14b871b57a Fixed the format_amount filter to deal with negative numbers with decimals correctly. 2023-03-05 11:48:34 +08:00
9d5fce2752 Fixed the documentation of the Journal report. 2023-03-04 20:14:08 +08:00
d333151731 Revised the documentation of the __get_journal_list view. 2023-03-04 20:12:13 +08:00
b2e500a714 Fixed the documentation of the get_default_journal_list view. 2023-03-04 20:10:59 +08:00
b705795b44 Moved the ReportType enumeration from the "accounting.report.report_chooser" module to the new "accounting.report.report_type" module. 2023-03-04 19:56:07 +08:00
250f4ff1ae Revised the imports in the "accounting.report.reports" module 2023-03-04 19:52:45 +08:00
6bed180790 Renamed the TransactionTypeEnum enum to TransactionType. 2023-03-04 19:39:13 +08:00
10fbc3f638 Renamed the "accounting.transaction.dispatcher" module to "accounting.transaction.operators". 2023-03-04 19:36:53 +08:00
f65dc6fc42 Renamed the TransactionType class to TransactionOperator. 2023-03-04 19:36:07 +08:00
9833bac6e4 Added the TransactionTypeEnum in the new "accounting.utils.txn_types" module to remove the dependency from the "accounting.report" module to the "accounting.transaction" module. 2023-03-04 19:32:36 +08:00
7d412b20d7 Moved the material floating action button template to add new transactions from the "accounting/transaction/include" directory to the "accounting/include" directory, and renamed it from add-new-material-fab.html to add-txn-material-fab.htm, as it will also be used in the reports, not only the transaction management. 2023-03-04 18:40:00 +08:00
9bfcd3c50c Added the journal report as the first accounting report. 2023-03-04 18:31:33 +08:00
55c2ce6695 Added Tempus Dominus to the CDN of the test site. 2023-03-04 14:36:10 +08:00
493677e0aa Added "crossorigin" to the CDN stylesheets in the test site. 2023-03-04 14:36:08 +08:00
710c26d016 Fixed the documentation in the JavaScript SummeryHelper class. 2023-03-04 13:45:30 +08:00
24415018b7 Reordered the properties in the JavaScript SummeryHelper class. 2023-03-04 13:45:29 +08:00
c50b9a2000 Removed the unused tab and page classes from the templates of the summary editor. 2023-03-04 11:52:45 +08:00
af9bd14eed Removed the unused tab ID from the template of the summary editor. 2023-03-04 11:52:45 +08:00
9e1ff16e96 Fixed the aria-labelledby in the template of the summary editor. 2023-03-04 11:52:45 +08:00
f7c1fd77f2 Added blank lines and documentation to the template of the summary editor. 2023-03-04 11:52:45 +08:00
641315537d Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the transaction order, account order, currency form, and the speed dial for the material floating action buttons. 2023-03-04 11:52:45 +08:00
a895bd8560 Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the drop-and-drop reorder. 2023-03-04 11:52:44 +08:00
ca86a08f3e Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the account form. 2023-03-04 11:52:44 +08:00
e118422441 Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the account selector. 2023-03-04 11:52:44 +08:00
b3777cffbf Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the transaction form. 2023-03-04 11:52:44 +08:00
39c9c17007 Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the summary editor to avoid messing up with the "this" object. 2023-03-04 11:52:44 +08:00
3ab4eacf9f Moved the "accounting.transaction.template_globals" module to "accounting.template_globals", for the two template globals will be used in the reports beside the transaction management. 2023-03-04 07:06:03 +08:00
cff3d1b6bd Revised the code order in the init_app function in the "accounting" module. 2023-03-04 07:01:03 +08:00
f41db78831 Revised the summary helper so that when the summary is changed with the tag changed, the on-change callback is run to check the tag button status. 2023-03-04 07:01:03 +08:00
73f7d14e7b Fixed so that the values of the input fields are trimmed before composing the summary when they are changed. 2023-03-04 07:01:03 +08:00
f6ed6b10a7 Revised the summary editor to allow the "*" start character as the multiplication operation in addition to the "×" times character. 2023-03-04 07:01:03 +08:00
b5aaee4d15 Renamed the "number" tab plane to "annotation". 2023-03-04 07:01:03 +08:00
c849d6b3d4 Revised the numer tab plane in the summary editor. 2023-03-04 07:01:03 +08:00
a9908a7df4 Simplified the regular expression in the populate method of the GeneralTagTab class in the summary editor. 2023-03-04 07:01:02 +08:00
063c769158 Renamed the variables in the summary editor. 2023-03-04 07:01:02 +08:00
f8e9871300 Fixed to trim the summary when it is changed in the summary editor. 2023-03-04 07:01:02 +08:00
78a62a9575 Added a "note" field to the summary editor. 2023-03-04 07:01:02 +08:00
85fde6219e Fixed an HTML ID in the summary editor modal. 2023-03-04 07:01:02 +08:00
4eb9346d8d Renamed summary helper to summary editor. 2023-03-04 07:00:46 +08:00
11966a52ba Fixed a variable name in the #initializeAccountQuery method of the JavaScript AccountSelector class. 2023-03-04 06:57:35 +08:00
8cf81b5459 Revised the documentation of the "accounting.account.queries", "accounting.base_account.queries", "accounting.currency.queries", and "accounting.transaction.queries" modules. 2023-03-04 06:57:35 +08:00
cc958a39b3 Moved the format_amount and format_date template filters from the "accounting.transaction.template_filters" module to the "accounting.template_filters" module, and rename the filters from "accounting_txn_format_amount" and "accounting_txn_format_date" to "accounting_format_amount" and "accounting_format_date", respectively. They will not only be used in the transaction management, but also the reports. 2023-03-04 06:57:10 +08:00
9065686cc5 Split the "accounting.transaction.template" module into the "accounting.transaction.template_filters" and "accounting.transaction.template_globals" modules. 2023-03-03 18:28:59 +08:00
9a41cb10a1 Rewrote the summary helper, added the TabPlane classes so that the internal states of the summary helper is stored in the tab plane objects instead of passing the as parameters and variables. 2023-03-03 18:09:36 +08:00
6957e52d0d Renamed the "accounting.account.query", "accounting.base_account.query", "accounting.currency.query", and "accounting.transaction.query" modules to "accounting.account.queries", "accounting.base_account.queries", "accounting.currency.queries", and "accounting.transaction.queries", respectively. There will be more than one query in the next report module. 2023-03-03 01:12:36 +08:00
9cd9e90be0 Renamed the variables in the tests of the AccountTestCase and CurrencyTestCase test cases, for simplicity. 2023-03-01 21:09:14 +08:00
2839dc60b4 Revised the documentation of the PREFIX constant in test_account.py, test_currency.py, and test_transaction.py. 2023-03-01 20:22:27 +08:00
f3548a2327 Advanced to version 0.4.0. 2023-03-01 01:49:08 +08:00
79883d6940 Changed the Sphinx documentation scheme from "nature" to "sphinx_rtd_theme", to prepare for publishing in the future. 2023-03-01 01:48:56 +08:00
b2bc993416 Replaced the #populate method with the #parseAndPopulate method that is used both when starting the summary helper and when the summary input is updated. 2023-03-01 01:45:38 +08:00
453b3f0da5 Renamed the #tagInputOnChange method to #onTagInputChange in the JavaScript summary helper. 2023-03-01 01:31:25 +08:00
63ae3f0746 Replace the is_in_use pseudo property of the Account data model with the AccountOption class, and revised the #getAccountCodeUsedInForm method of the SummaryHelper, to solve the issue that the list of used accounts should be different for debit and credit entries. 2023-03-01 01:28:25 +08:00
da4cc6489f Removed the direction arrows from the tab navigation in the summary helper. 2023-03-01 00:59:40 +08:00
1102a3a4f3 Updated the translation. 2023-03-01 00:51:58 +08:00
1402a12f04 Simplified the logic in the add_txn function in testlib_txn.py. 2023-03-01 00:51:24 +08:00
f049b5d7ee Revised the form data used in the SummeryHelperTestCase test case, to avoid problems with SonarQube. 2023-03-01 00:51:24 +08:00
14ed4ca354 Added the #initializeTagButtons and #tagInputOnChange methods to the JavaScript SummaryHelper to simplify the code. 2023-03-01 00:51:24 +08:00
535ff96ab3 Revised the JavaScript regular expressions used in the summary helper, as suggested by SonarQube for security. 2023-03-01 00:51:24 +08:00
57482f81fc Revised the transaction form to start a new journal entry with the journal entry form instead of the summary helper, because it feels strange when the user want to leave the summary empty. 2023-03-01 00:51:24 +08:00
a31ce3c400 Replaced the function-based JavaScript account selector with the AccountSelector class that does things better. 2023-03-01 00:51:11 +08:00
319f0aed90 Fixed a documentation in the JavaScript summary helper. 2023-02-28 22:54:20 +08:00
826dcf0f86 Revised the documentation of the JavaScript for the summary helper. 2023-02-28 22:47:04 +08:00
b2411aee74 Updated the Sphinx documentation. 2023-02-28 22:44:40 +08:00
731acdced0 Revised the HTML in the summary helper template. 2023-02-28 22:41:56 +08:00
35b3bca1e6 Renamed the variables for the button elements in the summary helper, to be clear. 2023-02-28 22:37:46 +08:00
3c413497ae Split the JavaScript for the account selector from transaction-form.js to account-selector.js, to modularize the complex JavaScript. 2023-02-28 22:33:14 +08:00
1b5e516413 Renamed the HTML ID and class name prefix of the account selector modal, for consistency. 2023-02-28 22:24:12 +08:00
20cb5cecc4 Renamed the accounting-selector-modal class to accounting-account-selector-modal in the account selector. 2023-02-28 22:14:03 +08:00
08dc24605d Replaced the forEach loops with the for-of loops in the JavaScript for the currency form, account form, and the drag-and-drop reorder library functions. 2023-02-28 22:09:39 +08:00
bb7e9e94ee Replaced the forEach loops with the for-of loops whenever appropriate in the JavaScript for the transaction form. 2023-02-28 22:00:19 +08:00
2680a1c872 Merged debit-account-modal.html and credit-account-modal.html into account-selector-modal.html, because they are almost the same. 2023-02-28 21:45:10 +08:00
20a7ce591c Renamed the account_selector_modals block to form_modals in the transaction form templates. 2023-02-28 21:37:08 +08:00
474e844ed9 Revised the loading of the summary helper so that only the required helpers are loaded, but not both the debit and credit helpers. 2023-02-28 21:35:02 +08:00
b34955f2fb Replaced the forEach loops with the for-of loops in the JavaScript summary helper. The for-of loops are more consistent with the other languages and the traditional for loops, and do not mess up with the "this" object. 2023-02-28 20:20:36 +08:00
2bd0f0f14d Fixed the target in the initShow method of the JavaScript summary helper. 2023-02-28 19:13:08 +08:00
8b77d9ff93 Added the suggested accounts to the summary helper. 2023-02-28 19:11:09 +08:00
a9c7360020 Renamed the variables in the #reset method of the JavaScript SummaryHelper class, for consistency. 2023-02-28 17:14:02 +08:00
d02c87602b Added validation to the summary helper. 2023-02-28 16:38:50 +08:00
9f966643b5 Added ARIA labels to the different pages in the summary helper. 2023-02-28 16:38:19 +08:00
5746e2a3d6 Added a missing amount filter to the debit entries of the transaction form. 2023-02-28 15:52:30 +08:00
d5c2231794 Added the summary helper for the transaction form. 2023-02-28 15:49:01 +08:00
fc8e257a10 Added missing documentation to the currencies_errors pseudo property of the TransactionForm form. 2023-02-28 09:36:20 +08:00
2e9bf382fb Revised the documentation of the "accounting.transaction.dispatcher" module. 2023-02-28 09:31:46 +08:00
de48c848da Revised the code in the common account shorts in testlib_txn.py. 2023-02-28 08:24:15 +08:00
9cdcc828a7 Added the add_txn function to testlib_txn.py and applied it in the transaction test cases. 2023-02-28 08:14:23 +08:00
b28d446d07 Advanced to version 0.3.1. 2023-02-28 00:16:20 +08:00
274a38a588 Fixed a localization error in the transaction detail. 2023-02-28 00:16:12 +08:00
fff89a9957 Replaced the direct database add with the relationship append in the JournalEntryCollector class, to fix the PostgreSQL error that the new journal entries are added when the transaction is not added yet. 2023-02-28 00:04:32 +08:00
5613657c8f Fixed the JavaScript filterAccountOptions function in the transaction form so that the accounting list is not hidden when there is no account in use. 2023-02-27 23:00:49 +08:00
26bb16dd40 Revised the translation. 2023-02-27 18:59:50 +08:00
f0d39bb27b Added the action button to convert a cash income or cash expense transaction to a transfer transaction. 2023-02-27 18:59:42 +08:00
4c17310ebf Fixed an error to recognize the current transaction type in the supplied URI in the with_type filter in the "accounting.transaction.template" module. 2023-02-27 18:47:19 +08:00
fd36672877 Revised the imports in the "accounting.transaction.views" module. 2023-02-27 18:44:33 +08:00
d67c57056b Added the accounting_txn_format_amount_input template filter to properly format the decimal amount for the number input fields. 2023-02-27 18:40:54 +08:00
59c55ef574 Fixed the amount display in the template of the journal entry sub-form. 2023-02-27 18:34:02 +08:00
155 changed files with 16179 additions and 3369 deletions

1
.gitignore vendored
View File

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

View File

@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
exclude src/accounting/static/js/dummy.js
include src/accounting/translations/*
include src/accounting/translations/*/LC_MESSAGES/*
include docs/*
@ -22,6 +23,7 @@ include docs/source/*
include docs/source/_static/*
include docs/source/_templates/*
include tests/*
exclude tests/test_temp.py
include tests/test_site/*
include tests/test_site/templates/*
include tests/test_site/translations/*

View File

@ -28,10 +28,10 @@ accounting.account.forms module
:undoc-members:
:show-inheritance:
accounting.account.query module
-------------------------------
accounting.account.queries module
---------------------------------
.. automodule:: accounting.account.query
.. automodule:: accounting.account.queries
:members:
:undoc-members:
:show-inheritance:

View File

@ -20,10 +20,10 @@ accounting.base\_account.converters module
:undoc-members:
:show-inheritance:
accounting.base\_account.query module
-------------------------------------
accounting.base\_account.queries module
---------------------------------------
.. automodule:: accounting.base_account.query
.. automodule:: accounting.base_account.queries
:members:
:undoc-members:
:show-inheritance:

View File

@ -28,10 +28,10 @@ accounting.currency.forms module
:undoc-members:
:show-inheritance:
accounting.currency.query module
--------------------------------
accounting.currency.queries module
----------------------------------
.. automodule:: accounting.currency.query
.. automodule:: accounting.currency.queries
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,69 @@
accounting.report.period package
================================
Submodules
----------
accounting.report.period.chooser module
---------------------------------------
.. automodule:: accounting.report.period.chooser
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.description module
-------------------------------------------
.. automodule:: accounting.report.period.description
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.month\_end module
------------------------------------------
.. automodule:: accounting.report.period.month_end
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.parser module
--------------------------------------
.. automodule:: accounting.report.period.parser
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.period module
--------------------------------------
.. automodule:: accounting.report.period.period
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.shortcuts module
-----------------------------------------
.. automodule:: accounting.report.period.shortcuts
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.specification module
---------------------------------------------
.. automodule:: accounting.report.period.specification
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report.period
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,69 @@
accounting.report.reports package
=================================
Submodules
----------
accounting.report.reports.balance\_sheet module
-----------------------------------------------
.. automodule:: accounting.report.reports.balance_sheet
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.income\_expenses module
-------------------------------------------------
.. automodule:: accounting.report.reports.income_expenses
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.income\_statement module
--------------------------------------------------
.. automodule:: accounting.report.reports.income_statement
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.journal module
----------------------------------------
.. automodule:: accounting.report.reports.journal
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.ledger module
---------------------------------------
.. automodule:: accounting.report.reports.ledger
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.search module
---------------------------------------
.. automodule:: accounting.report.reports.search
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.trial\_balance module
-----------------------------------------------
.. automodule:: accounting.report.reports.trial_balance
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report.reports
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,47 @@
accounting.report package
=========================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.report.period
accounting.report.reports
accounting.report.utils
Submodules
----------
accounting.report.converters module
-----------------------------------
.. automodule:: accounting.report.converters
:members:
:undoc-members:
:show-inheritance:
accounting.report.template\_filters module
------------------------------------------
.. automodule:: accounting.report.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.report.views module
------------------------------
.. automodule:: accounting.report.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,77 @@
accounting.report.utils package
===============================
Submodules
----------
accounting.report.utils.base\_page\_params module
-------------------------------------------------
.. automodule:: accounting.report.utils.base_page_params
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.base\_report module
-------------------------------------------
.. automodule:: accounting.report.utils.base_report
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.csv\_export module
------------------------------------------
.. automodule:: accounting.report.utils.csv_export
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.ie\_account module
------------------------------------------
.. automodule:: accounting.report.utils.ie_account
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.option\_link module
-------------------------------------------
.. automodule:: accounting.report.utils.option_link
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.report\_chooser module
----------------------------------------------
.. automodule:: accounting.report.utils.report_chooser
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.report\_type module
-------------------------------------------
.. automodule:: accounting.report.utils.report_type
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.urls module
-----------------------------------
.. automodule:: accounting.report.utils.urls
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report.utils
:members:
:undoc-members:
:show-inheritance:

View File

@ -10,6 +10,7 @@ Subpackages
accounting.account
accounting.base_account
accounting.currency
accounting.report
accounting.transaction
accounting.utils
@ -32,6 +33,22 @@ accounting.models module
:undoc-members:
:show-inheritance:
accounting.template\_filters module
-----------------------------------
.. automodule:: accounting.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.template\_globals module
-----------------------------------
.. automodule:: accounting.template_globals
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

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

View File

@ -1,6 +1,15 @@
accounting.transaction package
==============================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.transaction.forms
accounting.transaction.utils
Submodules
----------
@ -12,34 +21,10 @@ accounting.transaction.converters module
:undoc-members:
:show-inheritance:
accounting.transaction.dispatcher module
----------------------------------------
accounting.transaction.template\_filters module
-----------------------------------------------
.. automodule:: accounting.transaction.dispatcher
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms module
-----------------------------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.query module
-----------------------------------
.. automodule:: accounting.transaction.query
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template module
--------------------------------------
.. automodule:: accounting.transaction.template
.. automodule:: accounting.transaction.template_filters
:members:
:undoc-members:
:show-inheritance:

View File

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

View File

@ -4,6 +4,14 @@ accounting.utils package
Submodules
----------
accounting.utils.cast module
----------------------------
.. automodule:: accounting.utils.cast
:members:
:undoc-members:
:show-inheritance:
accounting.utils.flash\_errors module
-------------------------------------
@ -60,6 +68,14 @@ accounting.utils.strip\_text module
:undoc-members:
:show-inheritance:
accounting.utils.txn\_types module
----------------------------------
.. automodule:: accounting.utils.txn_types
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------

View File

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

View File

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

View File

@ -17,13 +17,12 @@
"""The accounting application.
"""
import typing as t
from pathlib import Path
from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import AbstractUserUtils
from accounting.utils.user import UserUtilityInterface
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data"
"""The data directory."""
def init_app(app: Flask, user_utils: AbstractUserUtils,
url_prefix: str = "/accounting",
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
def init_app(app: Flask, user_utils: UserUtilityInterface,
url_prefix: str = "/accounting") -> None:
"""Initialize the application.
:param app: The Flask application.
:param user_utils: The user utilities.
: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.
"""
# The database instance must be set before loading everything
@ -58,11 +51,25 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
template_folder="templates",
static_folder="static")
from .template_filters import format_amount, format_date, default
bp.add_app_template_filter(format_amount, "accounting_format_amount")
bp.add_app_template_filter(format_date, "accounting_format_date")
bp.add_app_template_filter(default, "accounting_default")
from .template_globals import currency_options, default_currency_code
bp.add_app_template_global(currency_options,
"accounting_currency_options")
bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code")
from . import locale
locale.init_app(app, bp)
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
next_uri.init_app(bp)
from . import base_account
base_account.init_app(app, bp)
@ -76,7 +83,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import transaction
transaction.init_app(app, bp)
from .utils import next_uri
next_uri.init_app(bp)
from . import report
report.init_app(app, bp)
app.register_blueprint(bp)

View File

@ -18,7 +18,6 @@
"""
import os
import re
from secrets import randbelow
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]
"""The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
@ -93,14 +92,36 @@ def init_accounts_command(username: str) -> None:
data: list[AccountData] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
else False
is_need_offset: bool = __is_need_offset(base.code)
data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
__add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.")
def __is_need_offset(base_code: str) -> bool:
"""Checks that whether entries in the account need offset.
:param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise.
"""
# Assets
if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}:
return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
"1581", "1611", "1851", ""}:
return True
return False
# Liabilities
if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}:
return False
return True
# Only assets and liabilities need offset
return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.
@ -113,7 +134,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_pay_off_needed=x[6],
is_need_offset=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]

View File

@ -53,6 +53,20 @@ class BaseAccountAvailable:
"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):
"""The form to create or edit an account."""
base_code = StringField(
@ -66,8 +80,9 @@ class AccountForm(FlaskForm):
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
"""The title."""
is_pay_off_needed = BooleanField()
"""Whether the the entries of this account need pay-off."""
is_need_offset = BooleanField(
validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset."""
def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object.
@ -87,7 +102,10 @@ class AccountForm(FlaskForm):
obj.base_code = self.base_code.data
obj.no = count + 1
obj.title = self.title.data
obj.is_pay_off_needed = self.is_pay_off_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:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk

View File

@ -14,7 +14,7 @@
# 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 query.
"""The queries for the account management.
"""
import sqlalchemy as sa
@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(l10n_matches)]
if k in gettext("Pay-off needed"):
sub_conditions.append(Account.is_pay_off_needed)
if k in gettext("Need offset"):
sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\

View File

@ -27,13 +27,14 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
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.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .query import get_account_query
from .queries import get_account_query
bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management."""
@ -86,7 +87,7 @@ def add_account() -> redirect:
form.populate_obj(account)
db.session.add(account)
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)))
@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect:
with db.session.no_autoflush:
form.populate_obj(account)
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)))
account.updated_by_id = get_current_user_pk()
account.updated_at = sa.func.now()
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)))
@ -159,7 +160,7 @@ def delete_account(account: Account) -> redirect:
account.delete()
sort_accounts_in(account.base_code, account.id)
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()))
@ -186,10 +187,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
form: AccountReorderForm = AccountReorderForm(base)
form.save_order()
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()))
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()))

View File

@ -14,7 +14,7 @@
# 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 base account query.
"""The queries for the base account management.
"""
import sqlalchemy as sa

View File

@ -34,7 +34,7 @@ def list_accounts() -> str:
:return: The account list.
"""
from .query import get_base_account_query
from .queries import get_base_account_query
accounts: list[BaseAccount] = get_base_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/base-account/list.html",

View File

@ -17,8 +17,6 @@
"""The forms for the currency management.
"""
from __future__ import annotations
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
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
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:
"""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 == "":
return
if form.obj_code is not None and form.obj_code == field.data:
@ -46,6 +41,11 @@ class CurrencyForm(FlaskForm):
raise ValidationError(lazy_gettext(
"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(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the code.")),

View File

@ -14,7 +14,7 @@
# 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 query.
"""The queries for the currency management.
"""
import sqlalchemy as sa

View File

@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
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.pagination import Pagination
@ -47,7 +48,7 @@ def list_currencies() -> str:
:return: The currency list.
"""
from .query import get_currency_query
from .queries import get_currency_query
currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html",
@ -88,7 +89,7 @@ def add_currency() -> redirect:
form.populate_obj(currency)
db.session.add(currency)
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)))
@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect:
with db.session.no_autoflush:
form.populate_obj(currency)
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)))
currency.updated_by_id = get_current_user_pk()
currency.updated_at = sa.func.now()
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)))
@ -161,7 +162,7 @@ def delete_currency(currency: Currency) -> redirect:
"""
currency.delete()
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")))
@ -182,4 +183,3 @@ def __get_detail_uri(currency: Currency) -> str:
:return: The detail URI of the currency.
"""
return url_for("accounting.currency.detail", currency=currency)

View File

@ -21,6 +21,7 @@ from __future__ import annotations
import re
import typing as t
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
@ -52,7 +53,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account.
"""
return F"{self.code} {self.title}"
return f"{self.code} {self.title}"
@property
def title(self) -> str:
@ -113,8 +114,8 @@ class Account(db.Model):
"""The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False)
"""The title."""
is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need pay-off."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offset."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
@ -141,17 +142,11 @@ class Account(db.Model):
entries = db.relationship("JournalEntry", back_populates="account")
"""The journal entries."""
__CASH = "1111-001"
CASH_CODE: str = "1111-001"
"""The code of the cash account,"""
__RECEIVABLE = "1141-001"
"""The code of the receivable account,"""
__PAYABLE = "2141-001"
"""The code of the payable account,"""
__ACCUMULATED_CHANGE = "3351-001"
ACCUMULATED_CHANGE_CODE: str = "3351-001"
"""The code of the accumulated-change account,"""
__BROUGHT_FORWARD = "3352-001"
"""The code of the brought-forward account,"""
__NET_CHANGE = "3353-001"
NET_CHANGE_CODE: str = "3353-001"
"""The code of the net-change account,"""
def __str__(self) -> str:
@ -159,7 +154,7 @@ class Account(db.Model):
: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}"
@property
def code(self) -> str:
@ -167,7 +162,7 @@ class Account(db.Model):
:return: The code.
"""
return F"{self.base_code}-{self.no:03d}"
return f"{self.base_code}-{self.no:03d}"
@property
def title(self) -> str:
@ -204,23 +199,50 @@ class Account(db.Model):
self.l10n.append(AccountL10n(locale=current_locale, title=value))
@property
def is_in_use(self) -> bool:
"""Returns whether the account is in use.
def is_real(self) -> bool:
"""Returns whether the account is a real account.
:return: True if the account is in use, or False otherwise.
:return: True if the account is a real account, or False otherwise.
"""
if not hasattr(self, "__is_in_use"):
setattr(self, "__is_in_use", len(self.entries) > 0)
return getattr(self, "__is_in_use")
return self.base_code[0] in {"1", "2", "3"}
@is_in_use.setter
def is_in_use(self, is_in_use: bool) -> None:
"""Sets whether the account is in use.
@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.
:param is_in_use: True if the account is in use, or False otherwise.
:return: None.
"""
setattr(self, "__is_in_use", is_in_use)
AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.id == self.id).delete()
@classmethod
def find_by_code(cls, code: str) -> t.Self | None:
@ -276,37 +298,13 @@ class Account(db.Model):
cls.base_code != "3353")\
.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
def cash(cls) -> t.Self:
"""Returns the cash account.
:return: The cash account
"""
return cls.find_by_code(cls.__CASH)
@classmethod
def receivable(cls) -> t.Self:
"""Returns the receivable account.
:return: The receivable account
"""
return cls.find_by_code(cls.__RECEIVABLE)
@classmethod
def payable(cls) -> t.Self:
"""Returns the payable account.
:return: The payable account
"""
return cls.find_by_code(cls.__PAYABLE)
return cls.find_by_code(cls.CASH_CODE)
@classmethod
def accumulated_change(cls) -> t.Self:
@ -314,45 +312,7 @@ class Account(db.Model):
:return: The accumulated-change account
"""
return cls.find_by_code(cls.__ACCUMULATED_CHANGE)
@classmethod
def brought_forward(cls) -> t.Self:
"""Returns the brought-forward account.
:return: The brought-forward account
"""
return cls.find_by_code(cls.__BROUGHT_FORWARD)
@classmethod
def net_change(cls) -> t.Self:
"""Returns the net-change account.
:return: The net-change account
"""
return cls.find_by_code(cls.__NET_CHANGE)
@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()
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
class AccountL10n(db.Model):
@ -411,7 +371,7 @@ class Currency(db.Model):
:return: The string representation of the currency.
"""
return F"{self.name} ({self.code})"
return f"{self.name} ({self.code})"
@property
def name(self) -> str:
@ -607,7 +567,7 @@ class Transaction(db.Model):
for currency in self.currencies:
if len(currency.debit) > 1:
return False
if currency.debit[0].account.code != "1111-001":
if currency.debit[0].account.code != Account.CASH_CODE:
return False
return True
@ -621,10 +581,25 @@ class Transaction(db.Model):
for currency in self.currencies:
if len(currency.credit) > 1:
return False
if currency.credit[0].account.code != "1111-001":
if currency.credit[0].account.code != Account.CASH_CODE:
return False
return True
@property
def can_delete(self) -> bool:
"""Returns whether the transaction can be deleted.
:return: True if the transaction can be deleted, or False otherwise.
"""
if not hasattr(self, "__can_delete"):
def has_offset() -> bool:
for entry in self.entries:
if len(entry.offsets) > 0:
return True
return False
setattr(self, "__can_delete", not has_offset())
return getattr(self, "__can_delete")
def delete(self) -> None:
"""Deletes the transaction.
@ -654,15 +629,15 @@ class JournalEntry(db.Model):
"""True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit."""
pay_off_target_id = db.Column(db.Integer,
original_entry_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the pay-off target entry."""
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
"""The ID of the original entry."""
original_entry = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True)
"""The pay-off target entry."""
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
"""The pay-off entries."""
"""The original entry."""
offsets = db.relationship("JournalEntry", back_populates="original_entry")
"""The offset entries."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
@ -681,6 +656,21 @@ class JournalEntry(db.Model):
amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount."""
def __str__(self) -> str:
"""Returns the string representation of the journal entry.
:return: The string representation of the journal entry.
"""
if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(summary)s %(amount)s",
date=format_date(self.transaction.date),
summary="" if self.summary is None
else self.summary,
amount=format_amount(self.amount)))
return getattr(self, "__str")
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
@ -697,3 +687,75 @@ class JournalEntry(db.Model):
:return: The account code.
"""
return self.account.code
@property
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit entry.
"""
return self.amount if self.is_debit else None
@property
def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset.
:return: True if the entry needs offset, or False otherwise.
"""
if not self.account.is_need_offset:
return False
if self.account.base_code[0] == "1" and not self.is_debit:
return False
if self.account.base_code[0] == "2" and self.is_debit:
return False
return True
@property
def credit(self) -> Decimal | None:
"""Returns the credit amount.
:return: The credit amount, or None if this is not a credit entry.
"""
return None if self.is_debit else self.amount
@property
def net_balance(self) -> Decimal:
"""Returns the net balance.
:return: The net balance.
"""
if not hasattr(self, "__net_balance"):
setattr(self, "__net_balance", self.amount + sum(
[x.amount if x.is_debit == self.is_debit else -x.amount
for x in self.offsets]))
return getattr(self, "__net_balance")
@net_balance.setter
def net_balance(self, net_balance: Decimal) -> None:
"""Sets the net balance.
:param net_balance: The net balance.
:return: None.
"""
setattr(self, "__net_balance", net_balance)
@property
def query_values(self) -> tuple[list[str], list[str]]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
def format_amount(value: Decimal) -> str:
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:]
txn_day: date = self.transaction.date
summary: str = "" if self.summary is None else self.summary
return ([summary],
[str(txn_day.year),
"{}/{}".format(txn_day.year, txn_day.month),
"{}/{}".format(txn_day.month, txn_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])

View File

@ -0,0 +1,35 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# 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 report management.
"""
from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import PeriodConverter, IncomeExpensesAccountConverter
app.url_map.converters["period"] = PeriodConverter
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
from .views import bp as report_bp
bp.register_blueprint(report_bp, url_prefix="/reports")

View File

@ -0,0 +1,79 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# 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 report management.
"""
import re
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from .period import Period, get_period
from .utils.ie_account import IncomeExpensesAccount
class PeriodConverter(BaseConverter):
"""The supplier converter to convert the period specification from and to
the corresponding period in the routes."""
def to_python(self, value: str) -> Period:
"""Converts a period specification to a period.
:param value: The period specification.
:return: The corresponding period.
"""
try:
return get_period(value)
except ValueError:
abort(404)
def to_url(self, value: Period) -> str:
"""Converts a period to its specification.
:param value: The period.
:return: Its specification.
"""
return value.spec
class IncomeExpensesAccountConverter(BaseConverter):
"""The supplier converter to convert the income and expenses log pseudo
account code from and to the corresponding pseudo account in the routes."""
def to_python(self, value: str) -> IncomeExpensesAccount:
"""Converts an account code to an account.
:param value: The account code.
:return: The corresponding account.
"""
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
return IncomeExpensesAccount.current_assets_and_liabilities()
if not re.match("^[12][12]", value):
abort(404)
account: Account | None = Account.find_by_code(value)
if account is None:
abort(404)
return IncomeExpensesAccount(account)
def to_url(self, value: IncomeExpensesAccount) -> str:
"""Converts an account to account code.
:param value: The account.
:return: Its code.
"""
return value.code

View File

@ -0,0 +1,22 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# 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 period utility.
"""
from .chooser import PeriodChooser
from .parser import get_period
from .period import Period

View File

@ -0,0 +1,97 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 period chooser.
This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import typing as t
from datetime import date
from accounting.models import Transaction
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
class PeriodChooser:
"""The period chooser."""
def __init__(self, get_url: t.Callable[[Period], str]):
"""Constructs a period chooser.
:param get_url: The callback to return the URL of the current report in
a period.
"""
self.__get_url: t.Callable[[Period], str] = get_url
"""The callback to return the URL of the current report in a period."""
# Shortcut periods
self.this_month_url: str = get_url(ThisMonth())
"""The URL for this month."""
self.last_month_url: str = get_url(LastMonth())
"""The URL for last month."""
self.since_last_month_url: str = get_url(SinceLastMonth())
"""The URL since last mint."""
self.this_year_url: str = get_url(ThisYear())
"""The URL for this year."""
self.last_year_url: str = get_url(LastYear())
"""The URL for last year."""
self.today_url: str = get_url(Today())
"""The URL for today."""
self.yesterday_url: str = get_url(Yesterday())
"""The URL for yesterday."""
self.all_url: str = get_url(AllTime())
"""The URL for all period."""
self.url_template: str = get_url(TemplatePeriod())
"""The URL template."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
start: date | None = None if first is None else first.date
# Attributes
self.data_start: date | None = start
"""The start of the data."""
self.has_data: bool = start is not None
"""Whether there is any data."""
self.has_last_month: bool = False
"""Where there is data in last month."""
self.has_last_year: bool = False
"""Whether there is data in last year."""
self.has_yesterday: bool = False
"""Whether there is data in yesterday."""
self.available_years: list[int] = []
"""The available years."""
if self.has_data:
today: date = date.today()
self.has_last_month = start < date(today.year, today.month, 1)
self.has_last_year = start.year < today.year
self.has_yesterday = start < today
if start.year < today.year - 1:
self.available_years \
= reversed(range(start.year, today.year - 1))
def year_url(self, year: int) -> str:
"""Returns the period URL of a year.
:param year: The year
:return: The period URL of the year.
"""
return self.__get_url(YearPeriod(year))

View File

@ -0,0 +1,179 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 period description composer.
"""
from datetime import date, timedelta
from accounting.locale import gettext
def get_desc(start: date | None, end: date | None) -> str:
"""Returns the period description.
:param start: The start of the period.
:param end: The end of the period.
:return: The period description.
"""
if start is None and end is None:
return gettext("for all time")
if start is None:
return __get_until_desc(end)
if end is None:
return __get_since_desc(start)
try:
return __get_year_desc(start, end)
except ValueError:
pass
try:
return __get_month_desc(start, end)
except ValueError:
pass
return __get_day_desc(start, end)
def __get_since_desc(start: date) -> str:
"""Returns the description without the end day.
:param start: The start of the period.
:return: The description without the end day.
"""
def get_start_desc() -> str:
"""Returns the description of the start day.
:return: The description of the start day.
"""
if start.month == 1 and start.day == 1:
return str(start.year)
if start.day == 1:
return __format_month(start)
return __format_day(start)
return gettext("since %(start)s", start=get_start_desc())
def __get_until_desc(end: date) -> str:
"""Returns the description without the start day.
:param end: The end of the period.
:return: The description without the start day.
"""
def get_end_desc() -> str:
"""Returns the description of the end day.
:return: The description of the end day.
"""
if end.month == 12 and end.day == 31:
return str(end.year)
if (end + timedelta(days=1)).day == 1:
return __format_month(end)
return __format_day(end)
return gettext("until %(end)s", end=get_end_desc())
def __get_year_desc(start: date, end: date) -> str:
"""Returns the description as a year range.
:param start: The start of the period.
:param end: The end of the period.
:return: The description as a year range.
:raise ValueError: The period is not a year range.
"""
if start.month != 1 or start.day != 1 \
or end.month != 12 or end.day != 31:
raise ValueError
start_text: str = str(start.year)
if start.year == end.year:
return __get_in_desc(start_text)
return __get_from_to_desc(start_text, str(end.year))
def __get_month_desc(start: date, end: date) -> str:
"""Returns the description as a month range.
:param start: The start of the period.
:param end: The end of the period.
:return: The description as a month range.
:raise ValueError: The period is not a month range.
"""
if start.day != 1 or (end + timedelta(days=1)).day != 1:
raise ValueError
start_text: str = __format_month(start)
if start.year == end.year and start.month == end.month:
return __get_in_desc(start_text)
if start.year == end.year:
return __get_from_to_desc(start_text, str(end.month))
return __get_from_to_desc(start_text, __format_month(end))
def __get_day_desc(start: date, end: date) -> str:
"""Returns the description as a day range.
:param start: The start of the period.
:param end: The end of the period.
:return: The description as a day range.
:raise ValueError: The period is a month or year range.
"""
start_text: str = __format_day(start)
if start == end:
return __get_in_desc(start_text)
if start.year == end.year and start.month == end.month:
return __get_from_to_desc(start_text, str(end.day))
if start.year == end.year:
end_month_day: str = f"{end.month}/{end.day}"
return __get_from_to_desc(start_text, end_month_day)
return __get_from_to_desc(start_text, __format_day(end))
def __format_month(month: date) -> str:
"""Formats a month.
:param month: The month.
:return: The formatted month.
"""
return f"{month.year}/{month.month}"
def __format_day(day: date) -> str:
"""Formats a day.
:param day: The day.
:return: The formatted day.
"""
return f"{day.year}/{day.month}/{day.day}"
def __get_in_desc(period: str) -> str:
"""Returns the description of a whole year, month, or day.
:param period: The time period.
:return: The description of a whole year, month, or day.
"""
return gettext("in %(period)s", period=period)
def __get_from_to_desc(start: str, end: str) -> str:
"""Returns the description of a separated start and end.
:param start: The start.
:param end: The end.
:return: The description of the separated start and end.
"""
return gettext("in %(start)s-%(end)s", start=start, end=end)

View File

@ -0,0 +1,31 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utility to return the end of a month.
"""
import calendar
from datetime import date
def month_end(day: date) -> date:
"""Returns the end day of month for a date.
:param day: The date.
:return: The end day of the month of that day.
"""
last_day: int = calendar.monthrange(day.year, day.month)[1]
return date(day.year, day.month, last_day)

View File

@ -0,0 +1,119 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 period specification parser.
"""
import calendar
import re
import typing as t
from datetime import date
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime
DATE_SPEC_RE: str = r"(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?"
"""The regular expression of a date specification."""
def get_period(spec: str | None = None) -> Period:
"""Returns a period instance.
:param spec: The period specification, or omit for the default.
:return: The period instance.
:raise ValueError: When the period specification is invalid.
"""
if spec is None:
return ThisMonth()
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(),
"this-year": lambda: ThisYear(),
"last-year": lambda: LastYear(),
"today": lambda: Today(),
"yesterday": lambda: Yesterday(),
"all-time": lambda: AllTime(),
}
if spec in named_periods:
return named_periods[spec]()
start, end = __parse_spec(spec)
if start is not None and end is not None and start > end:
raise ValueError
return Period(start, end)
def __parse_spec(text: str) -> tuple[date | None, date | None]:
"""Parses the period specification.
:param text: The period specification.
:return: The start and end day of the period. The start and end day
may be None.
:raise ValueError: When the date is invalid.
"""
if text == "-":
return None, None
m = re.match(f"^{DATE_SPEC_RE}$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
__get_end(m[1], m[2], m[3])
m = re.match(f"^{DATE_SPEC_RE}-$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), None
m = re.match(f"-{DATE_SPEC_RE}$", text)
if m is not None:
return None, __get_end(m[1], m[2], m[3])
m = re.match(f"^{DATE_SPEC_RE}-{DATE_SPEC_RE}$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
__get_end(m[4], m[5], m[6])
raise ValueError
def __get_start(year: str, month: str | None, day: str | None) -> date:
"""Returns the start of the period from the date representation.
:param year: The year.
:param month: The month, if any.
:param day: The day, if any.
:return: The start of the period.
:raise ValueError: When the date is invalid.
"""
if day is not None:
return date(int(year), int(month), int(day))
if month is not None:
return date(int(year), int(month), 1)
return date(int(year), 1, 1)
def __get_end(year: str, month: str | None, day: str | None) -> date:
"""Returns the end of the period from the date representation.
:param year: The year.
:param month: The month, if any.
:param day: The day, if any.
:return: The end of the period.
:raise ValueError: When the date is invalid.
"""
if day is not None:
return date(int(year), int(month), int(day))
if month is not None:
year_n: int = int(year)
month_n: int = int(month)
day_n: int = calendar.monthrange(year_n, month_n)[1]
return date(year_n, month_n, day_n)
return date(int(year), 12, 31)

View File

@ -0,0 +1,129 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 date period.
This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import typing as t
from datetime import date, timedelta
from .description import get_desc
from .month_end import month_end
from .specification import get_spec
class Period:
"""A date period."""
def __init__(self, start: date | None, end: date | None):
"""Constructs a new date period.
:param start: The start date, or None from the very beginning.
:param end: The end date, or None till no end.
"""
self.start: date | None = start
"""The start of the period."""
self.end: date | None = end
"""The end of the period."""
self.is_default: bool = False
"""Whether the is the default period."""
self.is_this_month: bool = False
"""Whether the period is this month."""
self.is_last_month: bool = False
"""Whether the period is last month."""
self.is_since_last_month: bool = False
"""Whether the period is since last month."""
self.is_this_year: bool = False
"""Whether the period is this year."""
self.is_last_year: bool = False
"""Whether the period is last year."""
self.is_today: bool = False
"""Whether the period is today."""
self.is_yesterday: bool = False
"""Whether the period is yesterday."""
self.is_all: bool = start is None and end is None
"""Whether the period is all time."""
self.spec: str = ""
"""The period specification."""
self.desc: str = ""
"""The text description."""
self.is_a_month: bool = False
"""Whether the period is a whole month."""
self.is_type_month: bool = False
"""Whether the period is for the month chooser."""
self.is_a_year: bool = False
"""Whether the period is a whole year."""
self.is_a_day: bool = False
"""Whether the period is a single day."""
self._set_properties()
def _set_properties(self) -> None:
"""Sets the following properties.
* self.spec
* self.desc
* self.is_a_month
* self.is_type_month
* self.is_a_year
* self.is_a_day
Override this method to set the properties in the subclasses, to skip
the calculation.
:return: None.
"""
self.spec = get_spec(self.start, self.end)
self.desc = get_desc(self.start, self.end)
if self.start is None or self.end is None:
return
self.is_a_month = self.start.day == 1 \
and self.end == month_end(self.start)
self.is_type_month = self.is_a_month
self.is_a_year = self.start == date(self.start.year, 1, 1) \
and self.end == date(self.start.year, 12, 31)
self.is_a_day = self.start == self.end
def is_year(self, year: int) -> bool:
"""Returns whether the period is the specific year period.
:param year: The year.
:return: True if the period is the year period, or False otherwise.
"""
if not self.is_a_year:
return False
return self.start.year == year
@property
def is_type_arbitrary(self) -> bool:
"""Returns whether this period is an arbitrary period.
:return: True if this is an arbitrary period, or False otherwise.
"""
return not self.is_type_month and not self.is_a_year \
and not self.is_a_day
@property
def before(self) -> t.Self | None:
"""Returns the period before this period.
:return: The period before this period.
"""
if self.start is None:
return None
return Period(None, self.start - timedelta(days=1))

View File

@ -0,0 +1,168 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 named shortcut periods.
"""
from datetime import date, timedelta
from accounting.locale import gettext
from .month_end import month_end
from .period import Period
class ThisMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
this_month_start: date = date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today))
self.is_default = True
self.is_this_month = True
def _set_properties(self) -> None:
self.spec = "this-month"
self.desc = gettext("This month")
self.is_a_month = True
self.is_type_month = True
class LastMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: date = date(year, month, 1)
super().__init__(start, month_end(start))
self.is_last_month = True
def _set_properties(self) -> None:
self.spec = "last-month"
self.desc = gettext("Last month")
self.is_a_month = True
self.is_type_month = True
class SinceLastMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: date = date(year, month, 1)
super().__init__(start, None)
self.is_since_last_month = True
def _set_properties(self) -> None:
self.spec = "since-last-month"
self.desc = gettext("Since last month")
self.is_type_month = True
class ThisYear(Period):
"""The period of this year."""
def __init__(self):
year: int = date.today().year
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
super().__init__(start, end)
self.is_this_year = True
def _set_properties(self) -> None:
self.spec = "this-year"
self.desc = gettext("This year")
self.is_a_year = True
class LastYear(Period):
"""The period of last year."""
def __init__(self):
year: int = date.today().year
start: date = date(year - 1, 1, 1)
end: date = date(year - 1, 12, 31)
super().__init__(start, end)
self.is_last_year = True
def _set_properties(self) -> None:
self.spec = "last-year"
self.desc = gettext("Last year")
self.is_a_year = True
class Today(Period):
"""The period of today."""
def __init__(self):
today: date = date.today()
super().__init__(today, today)
self.is_today = True
def _set_properties(self) -> None:
self.spec = "today"
self.desc = gettext("Today")
self.is_a_day = True
class Yesterday(Period):
"""The period of yesterday."""
def __init__(self):
yesterday: date = date.today() - timedelta(days=1)
super().__init__(yesterday, yesterday)
self.is_yesterday = True
def _set_properties(self) -> None:
self.spec = "yesterday"
self.desc = gettext("Yesterday")
self.is_a_day = True
class AllTime(Period):
"""The period of all time."""
def __init__(self):
super().__init__(None, None)
self.is_all = True
def _set_properties(self) -> None:
self.spec = "all-time"
self.desc = gettext("All")
class TemplatePeriod(Period):
"""The period template."""
def __init__(self):
super().__init__(None, None)
def _set_properties(self) -> None:
self.spec = "PERIOD"
class YearPeriod(Period):
"""A year period."""
def __init__(self, year: int):
"""Constructs a year period.
:param year: The year.
"""
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
super().__init__(start, end)

View File

@ -0,0 +1,120 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 period specification composer.
"""
from datetime import date, timedelta
def get_spec(start: date | None, end: date | None) -> str:
"""Returns the period specification.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification.
"""
if start is None and end is None:
return "-"
if end is None:
return __get_since_spec(start)
if start is None:
return __get_until_spec(end)
try:
return __get_year_spec(start, end)
except ValueError:
pass
try:
return __get_month_spec(start, end)
except ValueError:
pass
return __get_day_spec(start, end)
def __get_since_spec(start: date) -> str:
"""Returns the period specification without the end day.
:param start: The start of the period.
:return: The period specification without the end day
"""
if start.month == 1 and start.day == 1:
return start.strftime("%Y-")
if start.day == 1:
return start.strftime("%Y-%m-")
return start.strftime("%Y-%m-%d-")
def __get_until_spec(end: date) -> str:
"""Returns the period specification without the start day.
:param end: The end of the period.
:return: The period specification without the start day
"""
if end.month == 12 and end.day == 31:
return end.strftime("-%Y")
if (end + timedelta(days=1)).day == 1:
return end.strftime("-%Y-%m")
return end.strftime("-%Y-%m-%d")
def __get_year_spec(start: date, end: date) -> str:
"""Returns the period specification as a year range.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification as a year range.
:raise ValueError: The period is not a year range.
"""
if start.month != 1 or start.day != 1 \
or end.month != 12 or end.day != 31:
raise ValueError
start_spec: str = start.strftime("%Y")
if start.year == end.year:
return start_spec
end_spec: str = end.strftime("%Y")
return f"{start_spec}-{end_spec}"
def __get_month_spec(start: date, end: date) -> str:
"""Returns the period specification as a month range.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification as a month range.
:raise ValueError: The period is not a month range.
"""
if start.day != 1 or (end + timedelta(days=1)).day != 1:
raise ValueError
start_spec: str = start.strftime("%Y-%m")
if start.year == end.year and start.month == end.month:
return start_spec
end_spec: str = end.strftime("%Y-%m")
return f"{start_spec}-{end_spec}"
def __get_day_spec(start: date, end: date) -> str:
"""Returns the period specification as a day range.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification as a day range.
:raise ValueError: The period is a month or year range.
"""
start_spec: str = start.strftime("%Y-%m-%d")
if start == end:
return start_spec
end_spec: str = end.strftime("%Y-%m-%d")
return f"{start_spec}-{end_spec}"

View File

@ -0,0 +1,26 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 reports.
"""
from .balance_sheet import BalanceSheet
from .income_expenses import IncomeExpenses
from .income_statement import IncomeStatement
from .journal import Journal
from .ledger import Ledger
from .search import Search
from .trial_balance import TrialBalance

View File

@ -0,0 +1,474 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 balance sheet.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
income_statement_url
class ReportAccount:
"""An account in the report."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the report.
:param account: The account.
:param amount: The amount.
:param url: The URL to the ledger of the account.
"""
self.account: Account = account
"""The account."""
self.amount: Decimal = amount
"""The amount of the account."""
self.url: str = url
"""The URL to the ledger of the account."""
class Subsection:
"""A subsection."""
def __init__(self, title: BaseAccount):
"""Constructs a subsection.
:param title: The title account.
"""
self.title: BaseAccount = title
"""The title account."""
self.accounts: list[ReportAccount] = []
"""The accounts in the subsection."""
@property
def total(self) -> Decimal:
"""Returns the total of the subsection.
:return: The total of the subsection.
"""
return sum([x.amount for x in self.accounts])
class Section:
"""A section."""
def __init__(self, title: BaseAccount):
"""Constructs a section.
:param title: The title account.
"""
self.title: BaseAccount = title
"""The title account."""
self.subsections: list[Subsection] = []
"""The subsections in the section."""
@property
def total(self) -> Decimal:
"""Returns the total of the section.
:return: The total of the section.
"""
return sum([x.total for x in self.subsections])
class AccountCollector:
"""The balance sheet account collector."""
def __init__(self, currency: Currency, period: Period):
"""Constructs the balance sheet account collector.
:param currency: The currency.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__period: Period = period
"""The period."""
self.accounts: list[ReportAccount] = self.__query_balances()
"""The balance sheet accounts."""
def __query_balances(self) -> list[ReportAccount]:
"""Queries and returns the balances.
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
select_balance: sa.Select \
= sa.select(Account.id, Account.base_code, Account.no,
balance_func)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\
.order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \
= db.session.execute(select_balance).all()
self.__all_accounts: list[Account] = Account.query\
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351",
Account.base_code == "3353")).all()
account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts}
self.accounts: list[ReportAccount] \
= [ReportAccount(account=account_by_id[x.id],
amount=x.balance,
url=ledger_url(self.__currency,
account_by_id[x.id],
self.__period))
for x in account_balances]
self.__add_accumulated()
self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
for balance in self.accounts:
if not balance.account.base_code.startswith("1"):
balance.amount = -balance.amount
return self.accounts
def __add_accumulated(self) -> None:
"""Adds the accumulated profit or loss to the balances.
:return: None.
"""
self.__add_owner_s_equity(Account.ACCUMULATED_CHANGE_CODE,
self.__query_accumulated(),
self.__period)
def __query_accumulated(self) -> Decimal | None:
"""Queries and returns the accumulated profit or loss.
:return: The accumulated profit or loss.
"""
if self.__period.start is None:
return None
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
Transaction.date < self.__period.start]
return self.__query_balance(conditions)
def __add_current_period(self) -> None:
"""Adds the accumulated profit or loss to the balances.
:return: None.
"""
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
self.__query_current_period(),
self.__period)
def __query_current_period(self) -> Decimal | None:
"""Queries and returns the net income or loss for current period.
:return: The net income or loss for current period.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return self.__query_balance(conditions)
@staticmethod
def __query_balance(conditions: list[sa.BinaryExpression])\
-> Decimal:
"""Queries the balance.
:param conditions: The SQL conditions for the balance.
:return: The balance.
"""
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2", "3"}])
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select_balance: sa.Select = sa.select(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
period: Period) -> None:
"""Adds an owner's equity balance.
:param code: The code of the account to add.
:param amount: The amount.
:param period: The period.
:return: None.
"""
if amount is None:
return
url: str = income_statement_url(self.__currency, period)
# There is an existing balance.
account_balance_by_code: dict[str, ReportAccount] \
= {x.account.code: x for x in self.accounts}
if code in account_balance_by_code:
balance: ReportAccount = account_balance_by_code[code]
balance.amount = balance.amount + amount
balance.url = url
return
# Add a new balance
account_by_code: dict[str, Account] \
= {x.code: x for x in self.__all_accounts}
self.accounts.append(ReportAccount(account=account_by_code[code],
amount=amount,
url=url))
class CSVHalfRow:
"""A half row in the CSV."""
def __init__(self, title: str | None, amount: Decimal | None):
"""The constructs a half row in the CSV.
:param title: The title.
:param amount: The amount.
"""
self.title: str | None = title
"""The title."""
self.amount: Decimal | None = amount
"""The amount."""
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self):
"""Constructs a row in the CSV."""
self.asset_title: str | None = None
"""The title of the asset."""
self.asset_amount: Decimal | None = None
"""The amount of the asset."""
self.liability_title: str | None = None
"""The title of the liability."""
self.liability_amount: Decimal | None = None
"""The amount of the liability."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.asset_title, self.asset_amount,
self.liability_title, self.liability_amount]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
period: Period,
has_data: bool,
assets: Section,
liabilities: Section,
owner_s_equity: Section):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param assets: The assets.
:param liabilities: The liabilities.
:param owner_s_equity: The owner's equity.
"""
self.currency: Currency = currency
"""The currency."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.assets: Section = assets
"""The assets."""
self.liabilities: Section = liabilities
"""The liabilities."""
self.owner_s_equity: Section = owner_s_equity
"""The owner's equity."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: balance_sheet_url(currency, x))
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return self.__has_data
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.BALANCE_SHEET,
currency=self.currency,
period=self.period)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: balance_sheet_url(x, self.period), self.currency)
class BalanceSheet(BaseReport):
"""The balance sheet."""
def __init__(self, currency: Currency, period: Period):
"""Constructs a balance sheet.
:param currency: The currency.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__period: Period = period
"""The period."""
self.__has_data: bool
"""True if there is any data, or False otherwise."""
self.__assets: Section
"""The assets."""
self.__liabilities: Section
"""The liabilities."""
self.__owner_s_equity: Section
"""The owner's equity."""
self.__set_data()
def __set_data(self) -> None:
"""Queries and sets assets, the liabilities, and the owner's equity
sections in the balance sheet.
:return: None.
"""
balances: list[ReportAccount] = AccountCollector(
self.__currency, self.__period).accounts
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
subtitles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
subsections: dict[str, Subsection] = {x.code: Subsection(x)
for x in subtitles}
for subsection in subsections.values():
sections[subsection.title.code[0]].subsections.append(subsection)
for balance in balances:
subsections[balance.account.base_code[:2]].accounts.append(balance)
self.__has_data = len(balances) > 0
self.__assets = sections["1"]
self.__liabilities = sections["2"]
self.__owner_s_equity = sections["3"]
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "balance-sheet-{currency}-{period}.csv"\
.format(currency=self.__currency.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets)
liability_rows: list[CSVHalfRow] = []
liability_rows.extend(self.__section_csv_rows(self.__liabilities))
liability_rows.append(CSVHalfRow(gettext("Total"),
self.__liabilities.total))
liability_rows.append(CSVHalfRow(None, None))
liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity))
liability_rows.append(CSVHalfRow(gettext("Total"),
self.__owner_s_equity.total))
rows: list[CSVRow] = [CSVRow() for _ in
range(max(len(asset_rows), len(liability_rows)))]
for i in range(len(rows)):
if i < len(asset_rows):
rows[i].asset_title = asset_rows[i].title
rows[i].asset_amount = asset_rows[i].amount
if i < len(liability_rows) and liability_rows[i].title is not None:
rows[i].liability_title = liability_rows[i].title
rows[i].liability_amount = liability_rows[i].amount
total: CSVRow = CSVRow()
total.asset_title = gettext("Total")
total.asset_amount = self.__assets.total
total.liability_title = gettext("Total")
total.liability_amount \
= self.__liabilities.total + self.__owner_s_equity.total
rows.append(total)
return rows
@staticmethod
def __section_csv_rows(section: Section) -> list[CSVHalfRow]:
"""Gathers the CSV rows for a section.
:param section: The section.
:return: The CSV rows for the section.
"""
rows: list[CSVHalfRow] \
= [CSVHalfRow(section.title.title.title(), None)]
for subsection in section.subsections:
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
for account in subsection.accounts:
rows.append(CSVHalfRow(f" {str(account.account).title()}",
account.amount))
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
params: PageParams = PageParams(currency=self.__currency,
period=self.__period,
has_data=self.__has_data,
assets=self.__assets,
liabilities=self.__liabilities,
owner_s_equity=self.__owner_s_equity)
return render_template("accounting/report/balance-sheet.html",
report=params)

View File

@ -0,0 +1,458 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 income and expenses log.
"""
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.ie_account import IncomeExpensesAccount
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
class ReportEntry:
"""An entry in the report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the report.
:param entry: The journal entry.
"""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.date: date | None = None
"""The date."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = None
"""The summary."""
self.income: Decimal | None = None
"""The income amount."""
self.expense: Decimal | None = None
"""The expense amount."""
self.balance: Decimal | None = None
"""The balance."""
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""The URL to the journal entry."""
if entry is not None:
self.date = entry.transaction.date
self.account = entry.account
self.summary = entry.summary
self.income = None if entry.is_debit else entry.amount
self.expense = entry.amount if entry.is_debit else None
self.note = entry.transaction.note
self.url = url_for("accounting.transaction.detail",
txn=entry.transaction)
class EntryCollector:
"""The report entry collector."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period):
"""Constructs the report entry collector.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: IncomeExpensesAccount = account
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: ReportEntry | None
"""The brought-forward entry."""
self.entries: list[ReportEntry]
"""The log entries."""
self.total: ReportEntry | None
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward entry, or None if the period starts from
the beginning.
"""
if self.__period.start is None:
return None
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func)\
.join(Transaction).join(Account)\
.filter(be(JournalEntry.currency_code == self.__currency.code),
self.__account_condition,
Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.account = Account.accumulated_change()
entry.summary = gettext("Brought forward")
if balance > 0:
entry.income = balance
elif balance < 0:
entry.expense = -balance
entry.balance = balance
return entry
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the log entries.
:return: The log entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
self.__account_condition]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
txn_with_account: sa.Select = sa.Select(Transaction.id).\
join(JournalEntry).join(Account).filter(*conditions)
return [ReportEntry(x)
for x in JournalEntry.query.join(Transaction).join(Account)
.filter(JournalEntry.transaction_id.in_(txn_with_account),
JournalEntry.currency_code == self.__currency.code,
sa.not_(self.__account_condition))
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit,
JournalEntry.no)
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction))]
@property
def __account_condition(self) -> sa.BinaryExpression:
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
return sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22"))
return Account.id == self.__account.id
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
:return: The total entry, or None if there is no data.
"""
if self.brought_forward is None and len(self.entries) == 0:
return None
entry: ReportEntry = ReportEntry()
entry.is_total = True
entry.summary = gettext("Total")
entry.income = sum([x.income for x in self.entries
if x.income is not None])
entry.expense = sum([x.expense for x in self.entries
if x.expense is not None])
entry.balance = entry.income - entry.expense
if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance
return entry
def __populate_balance(self) -> None:
"""Populates the balance of the entries.
:return: None.
"""
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance
for entry in self.entries:
if entry.income is not None:
balance = balance + entry.income
if entry.expense is not None:
balance = balance - entry.expense
entry.balance = balance
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, txn_date: date | str | None,
account: str | None,
summary: str | None,
income: str | Decimal | None,
expense: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param account: The account.
:param summary: The summary.
:param income: The income.
:param expense: The expense.
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = txn_date
"""The date."""
self.account: str | None = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.income: str | Decimal | None = income
"""The income."""
self.expense: str | Decimal | None = expense
"""The expense."""
self.balance: str | Decimal | None = balance
"""The balance."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.account, self.summary,
self.income, self.expense, self.balance, self.note]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: IncomeExpensesAccount,
period: Period,
has_data: bool,
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry.
:param entries: The log entries.
:param total: The total entry.
"""
self.currency: Currency = currency
"""The currency."""
self.account: IncomeExpensesAccount = account
"""The account."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportEntry] = pagination
"""The pagination."""
self.brought_forward: ReportEntry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[ReportEntry] = entries
"""The report entries."""
self.total: ReportEntry | None = total
"""The total entry."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_expenses_url(currency, account, x))
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return self.__has_data
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
if self.account.account is None:
return ReportChooser(ReportType.INCOME_EXPENSES,
currency=self.currency,
account=Account.cash(),
period=self.period)
return ReportChooser(ReportType.INCOME_EXPENSES,
currency=self.currency,
account=self.account.account,
period=self.period)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: income_expenses_url(x, self.account, self.period),
self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
current_al: IncomeExpensesAccount \
= IncomeExpensesAccount.current_assets_and_liabilities()
options: list[OptionLink] \
= [OptionLink(str(current_al),
income_expenses_url(self.currency, current_al,
self.period),
self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.join(Account)\
.filter(be(JournalEntry.currency_code == self.currency.code),
sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22")))\
.group_by(JournalEntry.account_id)
options.extend([OptionLink(str(x),
income_expenses_url(
self.currency,
IncomeExpensesAccount(x),
self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()])
return options
class IncomeExpenses(BaseReport):
"""The income and expenses log."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period):
"""Constructs an income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: IncomeExpensesAccount = account
"""The account."""
self.__period: Period = period
"""The period."""
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: ReportEntry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[ReportEntry] = collector.entries
"""The report entries."""
self.__total: ReportEntry | None = collector.total
"""The total entry."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "income-expenses-{currency}-{account}-{period}.csv"\
.format(currency=self.__currency.code, account=self.__account.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
gettext("Summary"), gettext("Income"),
gettext("Expense"), gettext("Balance"),
gettext("Note"))]
if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account).title(),
self.__brought_forward.summary,
self.__brought_forward.income,
self.__brought_forward.expense,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
x.income, x.expense, x.balance, x.note)
for x in self.__entries])
if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, None,
self.__total.income, self.__total.expense,
self.__total.balance, None))
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
all_entries: list[ReportEntry] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
if self.__total is not None:
all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0
brought_forward: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
entries=page_entries,
total=total)
return render_template("accounting/report/income-expenses.html",
report=params)

View File

@ -0,0 +1,325 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 income statement.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, income_statement_url
class ReportAccount:
"""An account in the report."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the report.
:param account: The account.
:param amount: The amount.
:param url: The URL to the ledger of the account.
"""
self.account: Account = account
"""The account."""
self.amount: Decimal = amount
"""The amount of the account."""
self.url: str = url
"""The URL to the ledger of the account."""
class AccumulatedTotal:
"""An accumulated total."""
def __init__(self, title: str):
"""Constructs an accumulated total.
:param title: The title.
"""
self.title: str = title
"""The account."""
self.amount: Decimal = Decimal("0")
"""The amount of the account."""
class Subsection:
"""A subsection."""
def __init__(self, title: BaseAccount):
"""Constructs a subsection.
:param title: The title account.
"""
self.title: BaseAccount = title
"""The title account."""
self.accounts: list[ReportAccount] = []
"""The accounts in the subsection."""
@property
def total(self) -> Decimal:
"""Returns the total of the subsection.
:return: The total of the subsection.
"""
return sum([x.amount for x in self.accounts])
class Section:
"""A section."""
def __init__(self, title: BaseAccount, accumulated_title: str):
"""Constructs a section.
:param title: The title account.
:param accumulated_title: The title for the accumulated total.
"""
self.title: BaseAccount = title
"""The title account."""
self.subsections: list[Subsection] = []
"""The subsections in the section."""
self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title)
@property
def total(self) -> Decimal:
"""Returns the total of the section.
:return: The total of the section.
"""
return sum([x.total for x in self.subsections])
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, text: str | None, amount: str | Decimal | None):
"""Constructs a row in the CSV.
:param text: The text.
:param amount: The amount.
"""
self.text: str | None = text
"""The text."""
self.amount: str | Decimal | None = amount
"""The amount."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.text, self.amount]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
period: Period,
has_data: bool,
sections: list[Section], ):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
"""
self.currency: Currency = currency
"""The currency."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.sections: list[Section] = sections
"""The sections in the income statement."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_statement_url(currency, x))
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return self.__has_data
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.INCOME_STATEMENT,
currency=self.currency,
period=self.period)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: income_statement_url(x, self.period), self.currency)
class IncomeStatement(BaseReport):
"""The income statement."""
def __init__(self, currency: Currency, period: Period):
"""Constructs an income statement.
:param currency: The currency.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__period: Period = period
"""The period."""
self.__has_data: bool
"""True if there is any data, or False otherwise."""
self.__sections: list[Section]
"""The sections."""
self.__set_data()
def __set_data(self) -> None:
"""Queries and sets data sections in the income statement.
:return: None.
"""
balances: list[ReportAccount] = self.__query_balances()
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
subtitles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
total_titles: dict[str, str] \
= {"4": gettext("total operating revenue"),
"5": gettext("gross income"),
"6": gettext("operating income"),
"7": gettext("before tax income"),
"8": gettext("after tax income"),
"9": gettext("net income or loss for current period")}
sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles}
subsections: dict[str, Subsection] \
= {x.code: Subsection(x) for x in subtitles}
for subsection in subsections.values():
sections[subsection.title.code[0]].subsections.append(subsection)
for balance in balances:
subsections[balance.account.base_code[:2]].accounts.append(balance)
self.__has_data = len(balances) > 0
self.__sections = sorted(sections.values(), key=lambda x: x.title.code)
total: Decimal = Decimal("0")
for section in self.__sections:
total = total + section.total
section.accumulated.amount = total
def __query_balances(self) -> list[ReportAccount]:
"""Queries and returns the balances.
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount),
else_=JournalEntry.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.id for x in balances])).all()}
return [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=ledger_url(self.__currency,
accounts[x.id],
self.__period))
for x in balances]
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "income-statement-{currency}-{period}.csv"\
.format(currency=self.__currency.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections:
rows.append(CSVRow(str(section.title).title(), None))
for subsection in section.subsections:
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
for account in subsection.accounts:
rows.append(CSVRow(f" {str(account.account).title()}",
account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title.title(),
section.accumulated.amount))
rows.append(CSVRow(None, None))
rows = rows[:-1]
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
params: PageParams = PageParams(currency=self.__currency,
period=self.__period,
has_data=self.__has_data,
sections=self.__sections)
return render_template("accounting/report/income-statement.html",
report=params)

View File

@ -0,0 +1,217 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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.
"""
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination
class ReportEntry:
"""An entry in the report."""
def __init__(self, entry: JournalEntry):
"""Constructs the entry in the report.
:param entry: The journal entry.
"""
self.entry: JournalEntry = entry
"""The journal entry."""
self.transaction: Transaction = entry.transaction
"""The transaction."""
self.currency: Currency = entry.currency
"""The account."""
self.account: Account = entry.account
"""The account."""
self.summary: str | None = entry.summary
"""The summary."""
self.debit: Decimal | None = entry.debit
"""The debit amount."""
self.credit: Decimal | None = entry.credit
"""The credit amount."""
self.amount: Decimal = entry.amount
"""The amount."""
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, txn_date: str | date,
currency: str,
account: str,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | date = txn_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.account: str = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.account, self.summary,
self.debit, self.credit, self.note]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, period: Period,
pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
"""Constructs the HTML page parameters.
:param period: The period.
:param entries: The journal entries.
"""
self.period: Period = period
"""The period."""
self.pagination: Pagination[JournalEntry] = pagination
"""The pagination."""
self.entries: list[JournalEntry] = entries
"""The entries."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: journal_url(x))
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.JOURNAL,
period=self.period)
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the report entries.
:param entries: The report entries.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code,
str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note)
for x in entries])
return rows
class Journal(BaseReport):
"""The journal."""
def __init__(self, period: Period):
"""Constructs a journal.
:param period: The period.
"""
self.__period: Period = period
"""The period."""
self.__entries: list[JournalEntry] = self.__query_entries()
"""The journal entries."""
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The journal entries.
"""
conditions: list[sa.BinaryExpression] = []
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return JournalEntry.query.join(Transaction)\
.filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(),
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"journal-{period_spec(self.__period)}.csv"
return csv_download(filename, get_csv_rows(self.__entries))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries, is_reversed=True)
params: PageParams = PageParams(period=self.__period,
pagination=pagination,
entries=pagination.list)
return render_template("accounting/report/journal.html",
report=params)

View File

@ -0,0 +1,409 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 ledger.
"""
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
class ReportEntry:
"""An entry in the report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the report.
:param entry: The journal entry.
"""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.date: date | None = None
"""The date."""
self.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
"""The credit amount."""
self.balance: Decimal | None = None
"""The balance."""
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""The URL to the journal entry."""
if entry is not None:
self.date = entry.transaction.date
self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
self.note = entry.transaction.note
self.url = url_for("accounting.transaction.detail",
txn=entry.transaction)
class EntryCollector:
"""The report entry collector."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the report entry collector.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: ReportEntry | None
"""The brought-forward entry."""
self.entries: list[ReportEntry]
"""The report entries."""
self.total: ReportEntry | None
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward entry, or None if the report starts from
the beginning.
"""
if self.__period.start is None:
return None
if self.__account.is_nominal:
return None
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\
.filter(be(JournalEntry.currency_code == self.__currency.code),
be(JournalEntry.account_id == self.__account.id),
Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.summary = gettext("Brought forward")
if balance > 0:
entry.debit = balance
elif balance < 0:
entry.credit = -balance
entry.balance = balance
return entry
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the report entries.
:return: The report entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
.filter(*conditions)
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(),
JournalEntry.no)
.options(selectinload(JournalEntry.transaction)).all()]
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
:return: The total entry, or None if there is no data.
"""
if self.brought_forward is None and len(self.entries) == 0:
return None
entry: ReportEntry = ReportEntry()
entry.is_total = True
entry.summary = gettext("Total")
entry.debit = sum([x.debit for x in self.entries
if x.debit is not None])
entry.credit = sum([x.credit for x in self.entries
if x.credit is not None])
entry.balance = entry.debit - entry.credit
if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance
return entry
def __populate_balance(self) -> None:
"""Populates the balance of the entries.
:return: None.
"""
if self.__account.is_nominal:
return None
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance
for entry in self.entries:
if entry.debit is not None:
balance = balance + entry.debit
if entry.credit is not None:
balance = balance - entry.credit
entry.balance = balance
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, txn_date: date | str | None,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = txn_date
"""The date."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.balance: str | Decimal | None = balance
"""The balance."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.summary,
self.debit, self.credit, self.balance, self.note]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: Account,
period: Period,
has_data: bool,
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry.
:param entries: The report entries.
:param total: The total entry.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportEntry] = pagination
"""The pagination."""
self.brought_forward: ReportEntry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[ReportEntry] = entries
"""The entries."""
self.total: ReportEntry | None = total
"""The total entry."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: ledger_url(currency, account, x))
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return self.__has_data
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.LEDGER,
currency=self.currency,
account=self.account,
period=self.period)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: ledger_url(x, self.account, self.period), self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.filter(be(JournalEntry.currency_code == self.currency.code))\
.group_by(JournalEntry.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()]
class Ledger(BaseReport):
"""The ledger."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs a ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__period: Period = period
"""The period."""
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: ReportEntry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[ReportEntry] = collector.entries
"""The report entries."""
self.__total: ReportEntry | None = collector.total
"""The total entry."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "ledger-{currency}-{account}-{period}.csv"\
.format(currency=self.__currency.code, account=self.__account.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Balance"), gettext("Note"))]
if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date,
self.__brought_forward.summary,
self.__brought_forward.debit,
self.__brought_forward.credit,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, x.summary,
x.debit, x.credit, x.balance, x.note)
for x in self.__entries])
if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None,
self.__total.debit, self.__total.credit,
self.__total.balance, None))
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
all_entries: list[ReportEntry] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
if self.__total is not None:
all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0
brought_forward: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
entries=page_entries,
total=total)
return render_template("accounting/report/ledger.html",
report=params)

View File

@ -0,0 +1,206 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# 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 search.
"""
from datetime import datetime
from decimal import Decimal
import sqlalchemy as sa
from flask import Response, render_template, request
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
Transaction, JournalEntry
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows
class EntryCollector:
"""The report entry collector."""
def __init__(self):
"""Constructs the report entry collector."""
self.entries: list[JournalEntry] = self.__query_entries()
"""The report entries."""
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The journal entries.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return []
conditions: list[sa.BinaryExpression] = []
for k in keywords:
sub_conditions: list[sa.BinaryExpression] \
= [JournalEntry.summary.contains(k),
JournalEntry.account_id.in_(
self.__get_account_condition(k)),
JournalEntry.currency_code.in_(
self.__get_currency_condition(k)),
JournalEntry.transaction_id.in_(
self.__get_transaction_condition(k))]
try:
sub_conditions.append(JournalEntry.amount == Decimal(k))
except ArithmeticError:
pass
conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.join(Transaction).filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit,
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
@staticmethod
def __get_account_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the account.
:param k: The keyword.
:return: The condition to filter the account.
"""
code: sa.BinaryExpression = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.contains(k))
conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k),
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(select_l10n)]
if k in gettext("Need offset"):
conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions))
@staticmethod
def __get_currency_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the currency.
:param k: The keyword.
:return: The condition to filter the currency.
"""
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.contains(k))
return sa.select(Currency.code).filter(
sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
Currency.code.in_(select_l10n)))
@staticmethod
def __get_transaction_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the transaction.
:param k: The keyword.
:return: The condition to filter the transaction.
"""
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
txn_date: datetime
try:
txn_date = datetime.strptime(k, "%Y")
conditions.append(
be(sa.extract("year", Transaction.date) == txn_date.year))
except ValueError:
pass
try:
txn_date = datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_(
sa.extract("year", Transaction.date) == txn_date.year,
sa.extract("month", Transaction.date) == txn_date.month))
except ValueError:
pass
try:
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("month", Transaction.date) == txn_date.month,
sa.extract("day", Transaction.date) == txn_date.day))
except ValueError:
pass
return sa.select(Transaction.id).filter(sa.or_(*conditions))
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
"""Constructs the HTML page parameters.
:param entries: The search result entries.
"""
self.pagination: Pagination[JournalEntry] = pagination
"""The pagination."""
self.entries: list[JournalEntry] = entries
"""The entries."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.SEARCH)
class Search(BaseReport):
"""The search."""
def __init__(self):
"""Constructs a search."""
self.__entries: list[JournalEntry] = EntryCollector().entries
"""The journal entries."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "search-{q}.csv".format(q=request.args["q"])
return csv_download(filename, get_csv_rows(self.__entries))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries, is_reversed=True)
params: PageParams = PageParams(pagination=pagination,
entries=pagination.list)
return render_template("accounting/report/search.html",
report=params)

View File

@ -0,0 +1,241 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 trial balance.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask import Response, render_template
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, trial_balance_url
class ReportAccount:
"""An account in the report."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the report.
:param account: The account.
:param amount: The amount.
:param url: The URL to the ledger of the account.
"""
self.account: Account = account
"""The account."""
self.debit: Decimal | None = amount if amount > 0 else None
"""The debit amount."""
self.credit: Decimal | None = -amount if amount < 0 else None
"""The credit amount."""
self.url: str = url
"""The URL to the ledger of the account."""
class Total:
"""The totals."""
def __init__(self, debit: Decimal, credit: Decimal):
"""Constructs the total in the trial balance.
:param debit: The debit amount.
:param credit: The credit amount.
"""
self.debit: Decimal | None = debit
"""The debit amount."""
self.credit: Decimal | None = credit
"""The credit amount."""
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, text: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None):
"""Constructs a row in the CSV.
:param text: The text.
:param debit: The debit amount.
:param credit: The credit amount.
"""
self.text: str | None = text
"""The text."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.text, self.debit, self.credit]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
period: Period,
accounts: list[ReportAccount],
total: Total):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param period: The period.
:param accounts: The accounts in the trial balance.
:param total: The total of the trial balance.
"""
self.currency: Currency = currency
"""The currency."""
self.period: Period = period
"""The period."""
self.accounts: list[ReportAccount] = accounts
"""The accounts in the trial balance."""
self.total: Total = total
"""The total of the trial balance."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: trial_balance_url(currency, x))
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.accounts) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.TRIAL_BALANCE,
currency=self.currency,
period=self.period)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: trial_balance_url(x, self.period), self.currency)
class TrialBalance(BaseReport):
"""The trial balance."""
def __init__(self, currency: Currency, period: Period):
"""Constructs a trial balance.
:param currency: The currency.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__period: Period = period
"""The period."""
self.__accounts: list[ReportAccount]
"""The accounts in the trial balance."""
self.__total: Total
"""The total of the trial balance."""
self.__set_data()
def __set_data(self) -> None:
"""Queries and sets data sections in the trial balance.
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.id for x in balances])).all()}
self.__accounts = [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=ledger_url(self.__currency,
accounts[x.id],
self.__period))
for x in balances]
self.__total = Total(
sum([x.debit for x in self.__accounts if x.debit is not None]),
sum([x.credit for x in self.__accounts if x.credit is not None]))
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "trial-balance-{currency}-{period}.csv"\
.format(currency=self.__currency.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
gettext("Credit"))]
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
for x in self.__accounts])
rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit))
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
params: PageParams = PageParams(currency=self.__currency,
period=self.__period,
accounts=self.__accounts,
total=self.__total)
return render_template("accounting/report/trial-balance.html",
report=params)

View File

@ -0,0 +1,37 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The template filters for the reports.
"""
from decimal import Decimal
from accounting.template_filters import format_amount as core_format_amount
def format_amount(value: Decimal | None) -> str | None:
"""Formats an amount for the report.
:param value: The amount.
:return: The formatted amount text.
"""
if value is None:
return ""
is_negative: bool = value < 0
formatted: str = core_format_amount(abs(value))
if is_negative:
formatted = f"({formatted})"
return formatted

View File

@ -0,0 +1,19 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 reports.
"""

View File

@ -0,0 +1,88 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# 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 page parameters of a report.
"""
import typing as t
from abc import ABC, abstractmethod
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Currency, JournalEntry
from accounting.utils.txn_types import TransactionType
from .option_link import OptionLink
from .report_chooser import ReportChooser
class BasePageParams(ABC):
"""The base HTML page parameters class."""
@property
@abstractmethod
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
@property
@abstractmethod
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
@property
def txn_types(self) -> t.Type[TransactionType]:
"""Returns the transaction types.
:return: The transaction types.
"""
return TransactionType
@property
def csv_uri(self) -> str:
uri: str = request.full_path if request.query_string \
else request.path
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "as"]
params.append(("as", "csv"))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
@staticmethod
def _get_currency_options(get_url: t.Callable[[Currency], str],
active_currency: Currency) -> list[OptionLink]:
"""Returns the currency options.
:param get_url: The callback to return the URL of a currency.
:param active_currency: The active currency.
:return: The currency options.
"""
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]

View File

@ -0,0 +1,40 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# 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 base report.
"""
from abc import ABC, abstractmethod
from flask import Response
class BaseReport(ABC):
"""The base report class."""
@abstractmethod
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
@abstractmethod
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""

View File

@ -0,0 +1,108 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 to export the report as CSV for download.
"""
import csv
from abc import ABC, abstractmethod
from datetime import timedelta, date
from decimal import Decimal
from io import StringIO
from flask import Response
from accounting.report.period import Period
class BaseCSVRow(ABC):
"""The base CSV row."""
@property
@abstractmethod
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
"""Exports the data rows as a CSV file for download.
:param filename: The download file name.
:param rows: The data rows.
:return: The response for download the CSV file.
"""
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response
def period_spec(period: Period) -> str:
"""Constructs the period specification to be used in the filename.
:param period: The period.
:return: The period specification to be used in the filename.
"""
start: str | None = __get_start_str(period.start)
end: str | None = __get_end_str(period.end)
if period.start is None and period.end is None:
return "all-time"
if start == end:
return start
if period.start is None:
return f"until-{end}"
if period.end is None:
return f"since-{start}"
return f"{start}-{end}"
def __get_start_str(start: date | None) -> str | None:
"""Returns the string representation of the start date.
:param start: The start date.
:return: The string representation of the start date, or None if the start
date is None.
"""
if start is None:
return None
if start.month == 1 and start.day == 1:
return str(start.year)
if start.day == 1:
return start.strftime("%Y%m")
return start.strftime("%Y%m%d")
def __get_end_str(end: date | None) -> str | None:
"""Returns the string representation of the end date.
:param end: The end date.
:return: The string representation of the end date, or None if the end
date is None.
"""
if end is None:
return None
if end.month == 12 and end.day == 31:
return str(end.year)
if (end + timedelta(days=1)).day == 1:
return end.strftime("%Y%m")
return end.strftime("%Y%m%d")

View File

@ -0,0 +1,86 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# 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 pseudo account for the income and expenses log.
"""
import typing as t
from flask import current_app
from accounting.locale import gettext
from accounting.models import Account
class IncomeExpensesAccount:
"""The pseudo account for the income and expenses log."""
CURRENT_AL_CODE: str = "0000-000"
"""The account code for the current assets and liabilities."""
def __init__(self, account: Account | None = None):
"""Constructs the pseudo account for the income and expenses log.
:param account: The actual account.
"""
self.account: Account | None = account
self.id: int = -1 if account is None else account.id
"""The ID."""
self.code: str = "" if account is None else account.code
"""The code."""
self.title: str = "" if account is None else account.title
"""The title."""
self.str: str = "" if account is None else str(account)
"""The string representation of the account."""
def __str__(self) -> str:
"""Returns the string representation of the account.
:return: The string representation of the account.
"""
return self.str
@classmethod
def current_assets_and_liabilities(cls) -> t.Self:
"""Returns the pseudo account for current assets and liabilities.
:return: The pseudo account for current assets and liabilities.
"""
account: cls = cls()
account.id = 0
account.code = cls.CURRENT_AL_CODE
account.title = gettext("current assets and liabilities")
account.str = account.title
return account
def default_ie_account_code() -> str:
"""Returns the default account code for the income and expenses log.
:return: The default account code for the income and expenses log.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
def default_ie_account() -> IncomeExpensesAccount:
"""Returns the default account for the income and expenses log.
:return: The default account for the income and expenses log.
"""
code: str = default_ie_account_code()
if code == IncomeExpensesAccount.CURRENT_AL_CODE:
return IncomeExpensesAccount.current_assets_and_liabilities()
return IncomeExpensesAccount(Account.find_by_code(code))

View File

@ -0,0 +1,41 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
# 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 option link.
"""
class OptionLink:
"""An option link."""
def __init__(self, title: str, url: str, is_active: bool,
fa_icon: str | None = None):
"""Constructs an option link.
:param title: The title.
:param url: The URL.
:param is_active: True if active, or False otherwise
:param fa_icon: The font-awesome icon, if any.
"""
self.title: str = title
"""The title."""
self.url: str = url
"""The URL."""
self.is_active: bool = is_active
"""True if active, or False otherwise."""
self.fa_icon: str | None = fa_icon
"""The font-awesome icon, if any."""

View File

@ -0,0 +1,159 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 report chooser.
This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import re
import typing as t
from flask_babel import LazyString
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount
from .option_link import OptionLink
from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url
class ReportChooser:
"""The report chooser."""
def __init__(self, active_report: ReportType,
period: Period | None = None,
currency: Currency | None = None,
account: Account | None = None):
"""Constructs the report chooser.
:param active_report: The active report.
:param period: The period.
:param currency: The currency.
:param account: The account.
"""
self.__active_report: ReportType = active_report
"""The currently active report."""
self.__period: Period = get_period() if period is None else period
"""The period."""
self.__currency: Currency = db.session.get(
Currency, default_currency_code()) \
if currency is None else currency
"""The currency."""
self.__account: Account = Account.cash() if account is None \
else account
"""The currency."""
self.__reports: list[OptionLink] = []
"""The links to the reports."""
self.current_report: str | LazyString = ""
"""The title of the current report."""
self.is_search: bool = active_report == ReportType.SEARCH
"""Whether the current report is the search page."""
self.__reports.append(self.__journal)
self.__reports.append(self.__ledger)
self.__reports.append(self.__income_expenses)
self.__reports.append(self.__trial_balance)
self.__reports.append(self.__income_statement)
self.__reports.append(self.__balance_sheet)
for report in self.__reports:
if report.is_active:
self.current_report = report.title
if self.is_search:
self.current_report = gettext("Search")
@property
def __journal(self) -> OptionLink:
"""Returns the journal.
:return: The journal.
"""
return OptionLink(gettext("Journal"), journal_url(self.__period),
self.__active_report == ReportType.JOURNAL,
fa_icon="fa-solid fa-book")
@property
def __ledger(self) -> OptionLink:
"""Returns the ledger.
:return: The ledger.
"""
return OptionLink(gettext("Ledger"),
ledger_url(self.__currency, self.__account,
self.__period),
self.__active_report == ReportType.LEDGER,
fa_icon="fa-solid fa-clipboard")
@property
def __income_expenses(self) -> OptionLink:
"""Returns the income and expenses log.
:return: The income and expenses log.
"""
account: Account = self.__account
if not re.match(r"[12][12]", account.base_code):
account: Account = Account.cash()
return OptionLink(gettext("Income and Expenses Log"),
income_expenses_url(self.__currency,
IncomeExpensesAccount(account),
self.__period),
self.__active_report == ReportType.INCOME_EXPENSES,
fa_icon="fa-solid fa-money-bill-wave")
@property
def __trial_balance(self) -> OptionLink:
"""Returns the trial balance.
:return: The trial balance.
"""
return OptionLink(gettext("Trial Balance"),
trial_balance_url(self.__currency, self.__period),
self.__active_report == ReportType.TRIAL_BALANCE,
fa_icon="fa-solid fa-scale-unbalanced")
@property
def __income_statement(self) -> OptionLink:
"""Returns the income statement.
:return: The income statement.
"""
return OptionLink(gettext("Income Statement"),
income_statement_url(self.__currency, self.__period),
self.__active_report == ReportType.INCOME_STATEMENT,
fa_icon="fa-solid fa-file-invoice-dollar")
@property
def __balance_sheet(self) -> OptionLink:
"""Returns the balance sheet.
:return: The balance sheet.
"""
return OptionLink(gettext("Balance Sheet"),
balance_sheet_url(self.__currency, self.__period),
self.__active_report == ReportType.BALANCE_SHEET,
fa_icon="fa-solid fa-scale-balanced")
def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports.
:return: The iteration of the reports.
"""
return iter(self.__reports)

View File

@ -0,0 +1,38 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 report types.
"""
from enum import Enum
class ReportType(Enum):
"""The report types."""
JOURNAL: str = "journal"
"""The journal."""
LEDGER: str = "ledger"
"""The ledger."""
INCOME_EXPENSES: str = "income-expenses"
"""The income and expenses log."""
TRIAL_BALANCE: str = "trial-balance"
"""The trial balance."""
INCOME_STATEMENT: str = "income-statement"
"""The income statement."""
BALANCE_SHEET: str = "balance-sheet"
"""The balance sheet."""
SEARCH: str = "search"
"""The search."""

View File

@ -0,0 +1,117 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# 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 to get the ledger URL.
"""
from flask import url_for
from accounting.models import Currency, Account
from accounting.report.period import Period
from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount, default_ie_account_code
def journal_url(period: Period) \
-> str:
"""Returns the URL of a journal.
:param period: The period.
:return: The URL of the journal.
"""
if period.is_default:
return url_for("accounting.report.journal-default")
return url_for("accounting.report.journal", period=period)
def ledger_url(currency: Currency, account: Account, period: Period) \
-> str:
"""Returns the URL of a ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The URL of the ledger.
"""
if period.is_default:
return url_for("accounting.report.ledger-default",
currency=currency, account=account)
return url_for("accounting.report.ledger",
currency=currency, account=account,
period=period)
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str:
"""Returns the URL of an income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The URL of the income and expenses log.
"""
if currency.code == default_currency_code() \
and account.code == default_ie_account_code() \
and period.is_default:
return url_for("accounting.report.default")
if period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=currency, account=account)
return url_for("accounting.report.income-expenses",
currency=currency, account=account,
period=period)
def trial_balance_url(currency: Currency, period: Period) -> str:
"""Returns the URL of a trial balance.
:param currency: The currency.
:param period: The period.
:return: The URL of the trial balance.
"""
if period.is_default:
return url_for("accounting.report.trial-balance-default",
currency=currency)
return url_for("accounting.report.trial-balance",
currency=currency, period=period)
def income_statement_url(currency: Currency, period: Period) -> str:
"""Returns the URL of an income statement.
:param currency: The currency.
:param period: The period.
:return: The URL of the income statement.
"""
if period.is_default:
return url_for("accounting.report.income-statement-default",
currency=currency)
return url_for("accounting.report.income-statement",
currency=currency, period=period)
def balance_sheet_url(currency: Currency, period: Period) -> str:
"""Returns the URL of a balance sheet.
:param currency: The currency.
:param period: The period.
:return: The URL of the balance sheet.
"""
if period.is_default:
return url_for("accounting.report.balance-sheet-default",
currency=currency)
return url_for("accounting.report.balance-sheet",
currency=currency, period=period)

View File

@ -0,0 +1,299 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# 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 report management.
"""
from flask import Blueprint, request, Response
from accounting import db
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.permission import has_permission, can_view
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search
from .template_filters import format_amount
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports."""
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
@bp.get("", endpoint="default")
@has_permission(can_view)
def get_default_report() -> str | Response:
"""Returns the income and expenses log in the default period.
:return: The income and expenses log in the default period.
"""
return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
default_ie_account(),
get_period())
@bp.get("journal", endpoint="journal-default")
@has_permission(can_view)
def get_default_journal() -> str | Response:
"""Returns the journal in the default period.
:return: The journal in the default period.
"""
return __get_journal(get_period())
@bp.get("journal/<period:period>", endpoint="journal")
@has_permission(can_view)
def get_journal(period: Period) -> str | Response:
"""Returns the journal.
:param period: The period.
:return: The journal in the period.
"""
return __get_journal(period)
def __get_journal(period: Period) -> str | Response:
"""Returns the journal.
:param period: The period.
:return: The journal in the period.
"""
report: Journal = Journal(period)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("ledger/<currency:currency>/<account:account>",
endpoint="ledger-default")
@has_permission(can_view)
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
"""Returns the ledger in the default period.
:param currency: The currency.
:param account: The account.
:return: The ledger in the default period.
"""
return __get_ledger(currency, account, get_period())
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
endpoint="ledger")
@has_permission(can_view)
def get_ledger(currency: Currency, account: Account, period: Period) \
-> str | Response:
"""Returns the ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The ledger in the period.
"""
return __get_ledger(currency, account, period)
def __get_ledger(currency: Currency, account: Account, period: Period) \
-> str | Response:
"""Returns the ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The ledger in the period.
"""
report: Ledger = Ledger(currency, account, period)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
endpoint="income-expenses-default")
@has_permission(can_view)
def get_default_income_expenses(currency: Currency,
account: IncomeExpensesAccount) \
-> str | Response:
"""Returns the income and expenses log in the default period.
:param currency: The currency.
:param account: The account.
:return: The income and expenses log in the default period.
"""
return __get_income_expenses(currency, account, get_period())
@bp.get(
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
endpoint="income-expenses")
@has_permission(can_view)
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str | Response:
"""Returns the income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The income and expenses log in the period.
"""
return __get_income_expenses(currency, account, period)
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str | Response:
"""Returns the income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The income and expenses log in the period.
"""
report: IncomeExpenses = IncomeExpenses(currency, account, period)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("trial-balance/<currency:currency>",
endpoint="trial-balance-default")
@has_permission(can_view)
def get_default_trial_balance(currency: Currency) -> str | Response:
"""Returns the trial balance in the default period.
:param currency: The currency.
:return: The trial balance in the default period.
"""
return __get_trial_balance(currency, get_period())
@bp.get("trial-balance/<currency:currency>/<period:period>",
endpoint="trial-balance")
@has_permission(can_view)
def get_trial_balance(currency: Currency, period: Period) -> str | Response:
"""Returns the trial balance.
:param currency: The currency.
:param period: The period.
:return: The trial balance in the period.
"""
return __get_trial_balance(currency, period)
def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
"""Returns the trial balance.
:param currency: The currency.
:param period: The period.
:return: The trial balance in the period.
"""
report: TrialBalance = TrialBalance(currency, period)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("income-statement/<currency:currency>",
endpoint="income-statement-default")
@has_permission(can_view)
def get_default_income_statement(currency: Currency) -> str | Response:
"""Returns the income statement in the default period.
:param currency: The currency.
:return: The income statement in the default period.
"""
return __get_income_statement(currency, get_period())
@bp.get("income-statement/<currency:currency>/<period:period>",
endpoint="income-statement")
@has_permission(can_view)
def get_income_statement(currency: Currency, period: Period) -> str | Response:
"""Returns the income statement.
:param currency: The currency.
:param period: The period.
:return: The income statement in the period.
"""
return __get_income_statement(currency, period)
def __get_income_statement(currency: Currency, period: Period) \
-> str | Response:
"""Returns the income statement.
:param currency: The currency.
:param period: The period.
:return: The income statement in the period.
"""
report: IncomeStatement = IncomeStatement(currency, period)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("balance-sheet/<currency:currency>",
endpoint="balance-sheet-default")
@has_permission(can_view)
def get_default_balance_sheet(currency: Currency) -> str | Response:
"""Returns the balance sheet in the default period.
:param currency: The currency.
:return: The balance sheet in the default period.
"""
return __get_balance_sheet(currency, get_period())
@bp.get("balance-sheet/<currency:currency>/<period:period>",
endpoint="balance-sheet")
@has_permission(can_view)
def get_balance_sheet(currency: Currency, period: Period) \
-> str | Response:
"""Returns the balance sheet.
:param currency: The currency.
:param period: The period.
:return: The balance sheet in the period.
"""
return __get_balance_sheet(currency, period)
def __get_balance_sheet(currency: Currency, period: Period) \
-> str | Response:
"""Returns the balance sheet.
:param currency: The currency.
:param period: The period.
:return: The balance sheet in the period.
"""
report: BalanceSheet = BalanceSheet(currency, period)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("search", endpoint="search")
@has_permission(can_view)
def search() -> str | Response:
"""Returns the search result.
:return: The search result.
"""
report: Search = Search()
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()

View File

@ -24,16 +24,6 @@
.accounting-clickable {
cursor: pointer;
}
.btn-group .btn .accounting-search-input {
min-height: calc(1em + .5rem + 2px);
padding: 0 0.5rem;
}
.btn-group .btn .accounting-search-label button {
border: none;
background-color: transparent;
color: inherit;
padding-right: 0;
}
.form-floating > textarea.form-control {
height: 6rem;
}
@ -41,6 +31,64 @@
color: #141619;
background-color: #D3D3D4;
}
.form-control.accounting-disabled {
background-color: #e9ecef;
}
/** The toolbar */
.accounting-toolbar {
display: flex;
}
.accounting-toolbar .input-group > .input-group-text {
padding: 0;
background-color: transparent;
color: inherit;
border: 0;
}
.accounting-toolbar .input-group > .input-group-text > button {
background-color: transparent;
color: inherit;
border: 0;
}
.accounting-toolbar form.btn > .form-control {
min-height: calc(1.5em + 2px);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
@media(min-width: 768px) {
.accounting-toolbar > .btn, .accounting-toolbar > .btn-group > .btn {
border-radius: 0;
}
.accounting-toolbar > .btn:first-child, .accounting-toolbar > .btn-group:first-child > .btn {
border-top-left-radius: 0.375rem;
border-bottom-left-radius: 0.375rem;
}
.accounting-toolbar > .btn:last-child, .accounting-toolbar > .btn-group:last-child > .btn {
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
.accounting-toolbar .btn.input-group {
width: 16rem;
}
}
@media(max-width:767px) {
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
height: 3.2rem;
width: 3.2rem;
border-radius: 50%;
margin-left: 1rem;
}
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
padding-top: 0.7rem;
}
.accounting-toolbar > form.btn {
width: 12rem;
height: 2.6rem;
border-radius: 0.375rem;
margin-top: 0.3rem;
margin-left: 1rem;
}
}
/** The card layout */
.accounting-card {
@ -58,6 +106,42 @@
font-size: 1.4rem;
color: #373b3e;
}
.accounting-sheet {
padding: 2em 1.5em;
margin: 1em;
background-color: #F8F9FA;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.accounting-sheet h2 {
border-bottom: thick double slategray;
}
/* Links between objects */
.accounting-original-entry {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-original-entry a {
color: inherit;
text-decoration: none;
}
.accounting-original-entry a:hover {
color: inherit;
}
.accounting-offset-entries {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-offset-entries ul li {
list-style: none;
}
.accounting-offset-entries ul li a {
color: inherit;
text-decoration: none;
}
.accounting-offset-entries ul li a:hover {
color: inherit;
}
/** The option selector */
.accounting-selector-list {
@ -79,21 +163,9 @@
.accounting-entry-control {
border-color: transparent;
}
.accounting-transaction-card {
padding: 2em 1.5em;
margin: 1em;
background-color: #F8F9FA;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.accounting-transaction-card h2 {
border-bottom: thick double slategray;
}
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2;
}
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
background-color: #c7dbd2;
}
.accounting-list-group-hover .list-group-item:hover {
background-color: #ececec;
}
@ -108,6 +180,143 @@
font-weight: bolder;
border-top: thick double slategray;
}
.accounting-entry-editor-original-entry-content {
width: calc(100% - 3rem);
}
/* The report table */
.accounting-report-table-header, .accounting-report-table-footer {
font-size: 1.2rem;
font-weight: bolder;
}
.accounting-report-table-header {
border-bottom: thin solid slategray;
}
.accounting-report-table-footer {
font-style: italic;
border-top: thin solid slategray;
}
.accounting-report-table-row {
display: grid;
}
a.accounting-report-table-row {
color: inherit;
text-decoration: none;
}
.accounting-report-table-row > div {
padding: .5rem;
}
.accounting-report-table .accounting-amount {
text-align: right;
}
.accounting-report-table-body .accounting-amount {
font-style: italic;
}
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
background-color: #f2f2f2;
}
.accounting-report-table-body .accounting-report-table-row:hover {
background-color: rgba(0, 0, 0, 0.075);
}
.accounting-journal-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
}
.accounting-ledger-real-table .accounting-report-table-row {
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
}
.accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
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 {
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
}
.accounting-income-expenses-table .accounting-report-table-footer .accounting-report-table-row {
grid-template-columns: 7fr 1fr 1fr 1fr;
}
.accounting-trial-balance-table .accounting-report-table-header {
border-bottom: thick double slategray;
}
.accounting-trial-balance-table .accounting-report-table-footer {
border-top: thick double slategray;
}
.accounting-trial-balance-table .accounting-report-table-row {
grid-template-columns: 3fr 1fr 1fr;
}
.accounting-income-statement-table .accounting-report-table-body {
border-top: thick double slategray;
border-bottom: thick double slategray;
}
.accounting-income-statement-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
.accounting-income-statement-section, .accounting-income-statement-total {
font-size: 1.2rem;
font-weight: bolder;
}
.accounting-income-statement-subsection, .accounting-income-statement-subtotal {
font-size: 1.1rem;
}
.accounting-income-statement-subtotal {
border-top: thin solid darkslategray;
}
/* Indents */
.accounting-income-statement-subsection {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.accounting-income-statement-account, .accounting-income-statement-subtotal {
margin-left: 1rem;
margin-right: 1rem;
}
/* A visual blank line between categories */
.accounting-income-statement-section {
margin-top: 2rem;
}
.accounting-income-statement-section:first-child {
margin-top: 0;
}
.accounting-income-statement-total {
margin-bottom: 2rem;
}
.accounting-income-statement-total:last-child {
margin-bottom: 0;
}
.accounting-balance-sheet-section, .accounting-balance-sheet-total {
font-size: 1.2rem;
font-weight: bolder;
}
.accounting-balance-sheet-section {
border-bottom: thick double darkslategray;
}
.accounting-balance-sheet-total {
border-top: thick double darkslategray;
}
.accounting-balance-sheet-subtotal {
font-size: 1.1rem;
font-weight: bolder;
border-top: thick double darkslategray;
}
.accounting-balance-sheet-account {
margin-left: 0.5rem;
}
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
font-style: italic;
}
/* The accounting report */
.accounting-mobile-journal-credit {
padding-left: 1rem;
}
/* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field {

View File

@ -20,93 +20,297 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/1
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeBaseAccountSelector();
document.getElementById("accounting-base-code")
.onchange = validateBase;
document.getElementById("accounting-title")
.onchange = validateTitle;
document.getElementById("accounting-form")
.onsubmit = validateForm;
document.addEventListener("DOMContentLoaded", () => {
AccountForm.initialize();
});
/**
* Initializes the base account selector.
* The account form.
*
* @private
*/
function initializeBaseAccountSelector() {
const selector = document.getElementById("accounting-base-selector-modal");
const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("accounting-base-content");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () {
base.classList.add("accounting-not-empty");
options.forEach(function (item) {
item.classList.remove("active");
});
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) {
selected.classList.add("active");
}
});
selector.addEventListener("hidden.bs.modal", function () {
if (baseCode.value === "") {
base.classList.remove("accounting-not-empty");
}
});
options.forEach(function (option) {
option.onclick = function () {
baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content;
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary")
btnClear.disabled = false;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
class AccountForm {
/**
* The base account selector
* @type {BaseAccountSelector}
*/
#baseAccountSelector;
/**
* The form element
* @type {HTMLFormElement}
*/
#formElement;
/**
* The control of the base account
* @type {HTMLDivElement}
*/
#baseControl;
/**
* The input of the base account
* @type {HTMLInputElement}
*/
#baseCode;
/**
* The base account
* @type {HTMLDivElement}
*/
#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 = function () {
baseCode.value = "";
baseContent.innerText = "";
btnClear.classList.add("btn-secondary")
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
}
initializeBaseAccountQuery();
}
/**
* Initializes the query on the base account options.
* The callback when the base account selector is closed.
*
*/
onBaseAccountSelectorClosed() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.remove("accounting-not-empty");
}
}
/**
* Sets the base account.
*
* @param code {string} the base account code
* @param text {string} the text for the base account
*/
setBaseAccount(code, text) {
this.#baseCode.value = code;
this.#base.innerText = text;
if (["1", "2", "3"].includes(code.substring(0, 1))) {
this.#isNeedOffsetControl.classList.remove("d-none");
this.#isNeedOffset.disabled = false;
} else {
this.#isNeedOffsetControl.classList.add("d-none");
this.#isNeedOffset.disabled = true;
this.#isNeedOffset.checked = false;
}
this.#validateBase();
}
/**
* Clears the base account.
*
*/
clearBaseAccount() {
this.#baseCode.value = "";
this.#base.innerText = "";
this.#validateBase();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateForm() {
let isValid = true;
isValid = this.#validateBase() && isValid;
isValid = this.#validateTitle() && isValid;
return isValid;
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateBase() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.add("is-invalid");
this.#baseError.innerText = A_("Please select the base account.");
return false;
}
this.#baseControl.classList.remove("is-invalid");
this.#baseError.innerText = "";
return true;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateTitle() {
this.#title.value = this.#title.value.trim();
if (this.#title.value === "") {
this.#title.classList.add("is-invalid");
this.#titleError.innerText = A_("Please fill in the title.");
return false;
}
this.#title.classList.remove("is-invalid");
this.#titleError.innerText = "";
return true;
}
/**
* The account form
* @type {AccountForm} the form
*/
static #form;
static initialize() {
this.#form = new AccountForm();
}
}
/**
* The base account selector.
*
* @private
*/
function initializeBaseAccountQuery() {
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"));
const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", function () {
if (query.value === "") {
options.forEach(function (option) {
option.classList.remove("d-none");
class BaseAccountSelector {
/**
* The account form
* @type {AccountForm}
*/
#form;
/**
* 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();
});
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
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");
}
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
options.forEach(function (option) {
for (const option of this.#options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
if (queryValue.includes(this.#query.value)) {
isMatched = true;
break;
}
@ -117,67 +321,38 @@ function initializeBaseAccountQuery() {
} else {
option.classList.add("d-none");
}
});
}
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
this.#optionList.classList.remove("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
* @private
* @param baseCode {string} the active base code
*/
function validateForm() {
let isValid = true;
isValid = validateBase() && isValid;
isValid = validateTitle() && isValid;
return isValid;
onOpen(baseCode) {
for (const option of this.#options) {
if (option.dataset.code === baseCode) {
option.classList.add("active");
} 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");
error.innerText = "";
return true;
if (baseCode === "") {
this.#clearButton.classList.add("btn-secondary")
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

@ -20,12 +20,13 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/2
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const onReorder = () => {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");

View File

@ -0,0 +1,224 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
"use strict";
/**
* The account selector.
*
*/
class AccountSelector {
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
#entryEditor;
/**
* The entry type
* @type {string}
*/
entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#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.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
*/
constructor(entryEditor, entryType) {
this.#entryEditor = entryEditor
this.entryType = entryType;
this.#prefix = "accounting-account-selector-" + entryType;
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
// noinspection JSValidateTypes
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
this.#more = document.getElementById(this.#prefix + "-more");
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
this.#more.onclick = () => {
this.#more.classList.add("d-none");
this.#filterOptions();
};
this.#clearButton.onclick = () => this.#entryEditor.clearAccount();
for (const option of this.#options) {
option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
}
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Filters the options.
*
*/
#filterOptions() {
const codesInUse = this.#getCodesUsedInForm();
let shouldAnyShow = false;
for (const option of this.#options) {
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
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");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#getCodesUsedInForm() {
const inUse = this.#entryEditor.form.getAccountCodesUsed(this.entryType);
if (this.#entryEditor.accountCode !== null) {
inUse.push(this.#entryEditor.accountCode);
}
return inUse
}
/**
* Returns whether an option should show.
*
* @param option {HTMLLIElement} the option
* @param more {HTMLLIElement} the more element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the option should show, or false otherwise
*/
#shouldOptionShow(option, more, inUse, query) {
const isQueryMatched = () => {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = () => {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* The callback when the account selector is shown.
*
*/
onOpen() {
this.#query.value = "";
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
if (option.dataset.code === this.#entryEditor.accountCode) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (this.#entryEditor.accountCode === null) {
this.#clearButton.classList.add("btn-secondary");
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;
}
}
/**
* Returns the account selector instances.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @return {{debit: AccountSelector, credit: AccountSelector}}
*/
static getInstances(entryEditor) {
const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) {
selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType);
}
return selectors;
}
}

View File

@ -20,155 +20,155 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/6
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.getElementById("accounting-code")
.onchange = validateCode;
document.getElementById("accounting-name")
.onchange = validateName;
document.getElementById("accounting-form")
.onsubmit = validateForm;
document.addEventListener("DOMContentLoaded", () => {
CurrencyForm.initialize();
});
/**
* The asynchronous validation result
* @type {object}
* The currency form.
*
* @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.
*
* @returns {boolean} true if valid, or false otherwise
* @private
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
async #validateForm() {
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
Object.keys(isAsyncValid).forEach(function (key) {
isValid = isAsyncValid[key] && isValid;
});
if (isValid) {
document.getElementById("accounting-form").submit()
}
isValid = await this.#validateCode() && isValid;
isValid = this.#validateName() && isValid;
return isValid;
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {boolean} true if valid, or false otherwise
* @private
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
async #validateCode(changeEvent = null) {
this.#code.value = this.#code.value.trim();
if (this.#code.value === "") {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
const blocklist = JSON.parse(this.#code.dataset.blocklist);
if (blocklist.includes(this.#code.value)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("This code is not available.");
return false;
}
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
const original = this.#code.dataset.original;
if (original === "" || this.#code.value !== original) {
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
const data = await response.json();
if (data["exists"]) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code conflicts with another currency.");
return false;
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
this.#code.classList.remove("is-invalid");
this.#codeError.innerText = "";
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.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateName() {
const field = document.getElementById("accounting-name");
const error = document.getElementById("accounting-name-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
#validateName() {
this.#name.value = this.#name.value.trim();
if (this.#name.value === "") {
this.#name.classList.add("is-invalid");
this.#nameError.innerText = A_("Please fill in the name.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
this.#name.classList.remove("is-invalid");
this.#nameError.innerText = "";
return true;
}
/**
* The form
* @type {CurrencyForm}
*/
static #form;
/**
* Initializes the currency form.
*
*/
static initialize() {
this.#form = new CurrencyForm();
}
}

View File

@ -20,6 +20,7 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/3
*/
"use strict";
/**
* Initializes the drag-and-drop reordering on a list.
@ -42,21 +43,21 @@ function initializeDragAndDropReordering(list, onReorder) {
function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
let dragged = null;
items.forEach(function (item) {
for (const item of items) {
item.draggable = true;
item.addEventListener("dragstart", function () {
item.addEventListener("dragstart", () => {
dragged = item;
dragged.classList.add("accounting-dragged");
});
item.addEventListener("dragover", function () {
item.addEventListener("dragover", () => {
onDragOver(dragged, item);
onReorder();
});
item.addEventListener("dragend", function () {
item.addEventListener("dragend", () => {
dragged.classList.remove("accounting-dragged");
dragged = null;
});
});
}
}
/**
@ -68,20 +69,20 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
*/
function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
items.forEach(function (item) {
item.addEventListener("touchstart", function () {
for (const item of items) {
item.addEventListener("touchstart", () => {
item.classList.add("accounting-dragged");
});
item.addEventListener("touchmove", function (event) {
item.addEventListener("touchmove", (event) => {
const touch = event.targetTouches[0];
const target = document.elementFromPoint(touch.pageX, touch.pageY);
onDragOver(item, target);
onReorder();
});
item.addEventListener("touchend", function () {
item.addEventListener("touchend", () => {
item.classList.remove("accounting-dragged");
});
});
}
}
/**

View File

@ -0,0 +1,596 @@
/* The Mia! Accounting Flask Project
* journal-entry-editor.js: The JavaScript for the journal entry editor
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
"use strict";
/**
* The journal entry editor.
*
*/
class JournalEntryEditor {
/**
* The transaction form
* @type {TransactionForm}
*/
form;
/**
* The journal entry editor
* @type {HTMLFormElement}
*/
#element;
/**
* The bootstrap modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-entry-editor"
/**
* The container of the original entry
* @type {HTMLDivElement}
*/
#originalEntryContainer;
/**
* The control of the original entry
* @type {HTMLDivElement}
*/
#originalEntryControl;
/**
* The original entry
* @type {HTMLDivElement}
*/
#originalEntry;
/**
* The error message of the original entry
* @type {HTMLDivElement}
*/
#originalEntryError;
/**
* The delete button of the original entry
* @type {HTMLButtonElement}
*/
#originalEntryDelete;
/**
* The control of the summary
* @type {HTMLDivElement}
*/
#summaryControl;
/**
* The summary
* @type {HTMLDivElement}
*/
#summary;
/**
* The error message of the summary
* @type {HTMLDivElement}
*/
#summaryError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
*/
#account;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The amount
* @type {HTMLInputElement}
*/
#amount;
/**
* The error message of the amount
* @type {HTMLDivElement}
*/
#amountError;
/**
* The journal entry to edit
* @type {JournalEntrySubForm|null}
*/
entry;
/**
* The debit or credit entry side sub-form
* @type {DebitCreditSideSubForm}
*/
#side;
/**
* Whether the journal entry needs offset
* @type {boolean}
*/
isNeedOffset = false;
/**
* The ID of the original entry
* @type {string|null}
*/
originalEntryId = null;
/**
* The date of the original entry
* @type {string|null}
*/
originalEntryDate = null;
/**
* The text of the original entry
* @type {string|null}
*/
originalEntryText = null;
/**
* The account code
* @type {string|null}
*/
accountCode = null;
/**
* The account text
* @type {string|null}
*/
accountText = null;
/**
* The summary
* @type {string|null}
*/
summary = null;
/**
* The amount
* @type {string}
*/
amount = "";
/**
* The summary editors
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
*/
#summaryEditors;
/**
* The account selectors
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
#accountSelectors;
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
originalEntrySelector;
/**
* Constructs a new journal entry editor.
*
* @param form {TransactionForm} the transaction form
*/
constructor(form) {
this.form = form;
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
this.#originalEntry = document.getElementById(this.#prefix + "-original-entry");
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
this.#account = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#summaryEditors = SummaryEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this);
this.originalEntrySelector = new OriginalEntrySelector();
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen(this, this.originalEntryId)
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen();
this.#amount.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.entry === null) {
this.entry = this.#side.addJournalEntry();
}
this.amount = this.#amount.value;
this.entry.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Saves the original entry from the original entry selector.
*
* @param originalEntry {OriginalEntry} the original entry
*/
saveOriginalEntry(originalEntry) {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
this.originalEntryId = originalEntry.id;
this.originalEntryDate = originalEntry.date;
this.originalEntryText = originalEntry.text;
this.#originalEntry.innerText = originalEntry.text;
this.#setEnableSummaryAccount(false);
if (originalEntry.summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = originalEntry.summary === ""? null: originalEntry.summary;
this.#summary.innerText = originalEntry.summary;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalEntry.accountCode;
this.accountText = originalEntry.accountText;
this.#account.innerText = originalEntry.accountText;
this.#amount.value = String(originalEntry.netBalance);
this.#amount.max = String(originalEntry.netBalance);
this.#amount.min = "0";
this.#validate();
}
/**
* Clears the original entry.
*
*/
clearOriginalEntry() {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#amount.max = "";
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#side.currency.getCurrencyCode();
}
/**
* Saves the summary from the summary editor.
*
* @param summary {string} the summary
*/
saveSummary(summary) {
if (summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = summary === ""? null: summary;
this.#summary.innerText = summary;
this.#validateSummary();
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
/**
* Saves the summary with the suggested account from the summary editor.
*
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise
*/
saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = accountCode;
this.accountText = accountText;
this.#account.innerText = accountText;
this.#validateAccount();
this.saveSummary(summary)
}
/**
* Clears the account.
*
*/
clearAccount() {
this.isNeedOffset = false;
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#validateAccount();
}
/**
* Sets the account.
*
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the journal entries in the account need offset or false otherwise
*/
saveAccount(code, text, isNeedOffset) {
this.isNeedOffset = isNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = code;
this.accountText = text;
this.#account.innerText = text;
this.#validateAccount();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalEntry() && isValid;
isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the original entry.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateOriginalEntry() {
this.#originalEntryControl.classList.remove("is-invalid");
this.#originalEntryError.innerText = "";
return true;
}
/**
* Validates the summary.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateSummary() {
this.#summary.classList.remove("is-invalid");
this.#summaryError.innerText = "";
return true;
}
/**
* Validates the account.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateAccount() {
if (this.accountCode === null) {
this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account.");
return false;
}
this.#accountControl.classList.remove("is-invalid");
this.#accountError.innerText = "";
return true;
}
/**
* Validates the amount.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateAmount() {
this.#amount.value = this.#amount.value.trim();
this.#amount.classList.remove("is-invalid");
if (this.#amount.value === "") {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
const amount =new Decimal(this.#amount.value);
if (amount.lessThanOrEqualTo(0)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in a positive amount.");
return false;
}
if (this.#amount.max !== "") {
if (amount.greaterThan(new Decimal(this.#amount.max))) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)});
return false;
}
}
if (this.#amount.min !== "") {
const min = new Decimal(this.#amount.min);
if (amount.lessThan(min)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
return false;
}
}
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
return true;
}
/**
* The callback when adding a new journal entry.
*
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
*/
onAddNew(side) {
this.entry = null;
this.#side = side;
this.entryType = this.#side.entryType;
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.#originalEntryControl.classList.remove("is-invalid");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#summaryControl.classList.remove("accounting-not-empty");
this.#summaryControl.classList.remove("is-invalid");
this.summary = null;
this.#summary.innerText = ""
this.#summaryError.innerText = ""
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#accountError.innerText = "";
this.#amount.value = "";
this.#amount.max = "";
this.#amount.min = "0";
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
/**
* The callback when editing a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
*/
onEdit(entry) {
this.entry = entry;
this.#side = entry.side;
this.entryType = this.#side.entryType;
this.isNeedOffset = entry.isNeedOffset();
this.originalEntryId = entry.getOriginalEntryId();
this.originalEntryDate = entry.getOriginalEntryDate();
this.originalEntryText = entry.getOriginalEntryText();
this.#originalEntry.innerText = this.originalEntryText;
if (this.originalEntryId === null) {
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
} else {
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
}
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null);
this.summary = entry.getSummary();
if (this.summary === null) {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summary.innerText = this.summary === null? "": this.summary;
if (entry.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.accountCode = entry.getAccountCode();
this.accountText = entry.getAccountText();
this.#account.innerText = this.accountText;
this.#amount.value = entry.getAmount() === null? "": String(entry.getAmount());
const maxAmount = this.#getMaxAmount();
this.#amount.max = maxAmount === null? "": maxAmount;
this.#amount.min = entry.getAmountMin() === null? "": String(entry.getAmountMin());
this.#validate();
}
/**
* Finds out the max amount.
*
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.originalEntryId === null) {
return null;
}
return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId);
}
/**
* Sets the enable status of the summary and account.
*
* @param isEnabled {boolean} true to enable, or false otherwise
*/
#setEnableSummaryAccount(isEnabled) {
if (isEnabled) {
this.#summaryControl.dataset.bsToggle = "modal";
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal";
this.#summaryControl.classList.remove("accounting-disabled");
this.#summaryControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal";
this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable");
} else {
this.#summaryControl.dataset.bsToggle = "";
this.#summaryControl.dataset.bsTarget = "";
this.#summaryControl.classList.add("accounting-disabled");
this.#summaryControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled");
this.#accountControl.classList.remove("accounting-clickable");
}
}
}

View File

@ -20,9 +20,10 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
initializeMaterialFabSpeedDial();
});
@ -34,7 +35,7 @@ document.addEventListener("DOMContentLoaded", function () {
function initializeMaterialFabSpeedDial() {
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
const fab = document.getElementById(btnFab.dataset.target);
btnFab.onclick = function () {
btnFab.onclick = () => {
if (fab.classList.contains("show")) {
fab.classList.remove("show");
} else {

View File

@ -0,0 +1,417 @@
/* The Mia! Accounting Flask Project
* original-entry-selector.js: The JavaScript for the original entry selector
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/3/10
*/
"use strict";
/**
* The original entry selector.
*
*/
class OriginalEntrySelector {
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-original-entry-selector";
/**
* The modal of the original entry editor
* @type {HTMLDivElement}
*/
#modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {OriginalEntry[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalEntry>}
*/
#optionById;
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
entryEditor;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The entry
*/
#entryType;
/**
* Constructs an original entry selector.
*
*/
constructor() {
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalEntry(this, element));
this.#optionById = {};
for (const option of this.#options) {
this.#optionById[option.id] = option;
}
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Returns the net balance for an original entry.
*
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
* @param form {TransactionForm} the transaction form
* @param originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry
*/
getNetBalance(currentEntry, form, originalEntryId) {
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
let otherOffset = new Decimal(0);
for (const otherEntry of otherEntries) {
if (otherEntry.getOriginalEntryId() === originalEntryId) {
const amount = otherEntry.getAmount();
if (amount !== null) {
otherOffset = otherOffset.plus(amount);
}
}
}
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset);
}
/**
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry
*
*/
#updateNetBalances() {
const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry);
const otherOffsets = {}
for (const otherEntry of otherEntries) {
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
const amount = otherEntry.getAmount();
if (otherOriginalEntryId === null || amount === null) {
continue;
}
if (!(otherOriginalEntryId in otherOffsets)) {
otherOffsets[otherOriginalEntryId] = new Decimal("0");
}
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
}
for (const option of this.#options) {
if (option.id in otherOffsets) {
option.updateNetBalance(otherOffsets[option.id]);
} else {
option.resetNetBalance();
}
}
}
/**
* Filters the options.
*
*/
#filterOptions() {
let hasAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#entryType, this.#currencyCode, this.#query.value)) {
option.setShown(true);
hasAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!hasAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
/**
* The callback when the original entry selector is shown.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param originalEntryId {string|null} the ID of the original entry
*/
onOpen(entryEditor, originalEntryId = null) {
this.entryEditor = entryEditor
this.#currencyCode = entryEditor.getCurrencyCode();
this.#entryType = entryEditor.entryType;
for (const option of this.#options) {
option.setActive(option.id === originalEntryId);
}
this.#query.value = "";
this.#updateNetBalances();
this.#filterOptions();
}
}
/**
* An original entry.
*
*/
class OriginalEntry {
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The ID
* @type {string}
*/
id;
/**
* The date
* @type {string}
*/
date;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
#entryType;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The account code
* @type {string}
*/
accountCode;
/**
* The account text
* @type {string}
*/
accountText;
/**
* The summary
* @type {string}
*/
summary;
/**
* The net balance, without the offset amounts on the form
* @type {Decimal}
*/
bareNetBalance;
/**
* The net balance
* @type {Decimal}
*/
netBalance;
/**
* The text of the net balance
* @type {HTMLSpanElement}
*/
netBalanceText;
/**
* The text representation of the original entry
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[][]}
*/
#queryValues;
/**
* Constructs an original entry.
*
* @param selector {OriginalEntrySelector} the original entry selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#selector = selector;
this.#element = element;
this.id = element.dataset.id;
this.date = element.dataset.date;
this.#entryType = element.dataset.entryType;
this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText;
this.summary = element.dataset.summary;
this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance");
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this);
}
/**
* Resets the net balance to its initial value, without the offset amounts on the form.
*
*/
resetNetBalance() {
if (this.netBalance !== this.bareNetBalance) {
this.netBalance = this.bareNetBalance;
this.#updateNetBalanceText();
}
}
/**
* Updates the net balance with an offset.
*
* @param offset {Decimal} the offset to be added to the net balance
*/
updateNetBalance(offset) {
this.netBalance = this.bareNetBalance.minus(offset);
this.#updateNetBalanceText();
}
/**
* Updates the text display of the net balance.
*
*/
#updateNetBalanceText() {
this.netBalanceText.innerText = formatDecimal(this.netBalance);
}
/**
* Returns whether the original matches.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @param currencyCode {string} the currency code
* @param query {string|null} the query term
*/
isMatched(entryType, currencyCode, query = null) {
return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.entryEditor.form.getDate()
&& this.#isEntryTypeMatches(entryType)
&& this.#currencyCode === currencyCode
&& this.#isQueryMatches(query);
}
/**
* Returns whether the original entry matches the entry type.
*
* @param entryType {string} the entry type, either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise
*/
#isEntryTypeMatches(entryType) {
return (entryType === "debit" && this.#entryType === "credit")
|| (entryType === "credit" && this.#entryType === "debit");
}
/**
* Returns whether the original entry matches the query.
*
* @param query {string|null} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
#isQueryMatches(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues[0]) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
for (const queryValue of this.#queryValues[1]) {
if (queryValue === query) {
return true;
}
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}

View File

@ -0,0 +1,426 @@
/* The Mia! Accounting Flask Project
* period-chooser.js: The JavaScript for the period chooser
*/
/* 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/4
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
PeriodChooser.initialize();
});
/**
* The period chooser.
*
*/
class PeriodChooser {
/**
* The prefix of the HTML ID and class
* @type {string}
*/
prefix;
/**
* The modal of the period chooser
* @type {HTMLDivElement}
*/
modal;
/**
* The tab planes
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
*/
tabPlanes = {};
/**
* Constructs the period chooser.
*
*/
constructor() {
this.prefix = "accounting-period-chooser";
this.modal = document.getElementById(this.prefix + "-modal");
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
}
}
/**
* The period chooser.
* @type {PeriodChooser}
*/
static #chooser;
/**
* Initializes the period chooser.
*
*/
static initialize() {
this.#chooser = new PeriodChooser();
}
}
/**
* A tab plane.
*
* @abstract
* @private
*/
class TabPlane {
/**
* The period chooser
* @type {PeriodChooser}
*/
chooser;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
prefix;
/**
* The tab
* @type {HTMLSpanElement}
*/
#tab;
/**
* The page
* @type {HTMLDivElement}
*/
#page;
/**
* Constructs a tab plane.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
this.chooser = chooser;
this.prefix = "accounting-period-chooser-" + this.tabId();
this.#tab = document.getElementById(this.prefix + "-tab");
this.#page = document.getElementById(this.prefix + "-page");
this.#tab.onclick = () => this.#switchToMe();
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() { throw new Error("Method not implemented.") };
/**
* Switches to the tab plane.
*
*/
#switchToMe() {
for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
tabPlane.#tab.classList.remove("active")
tabPlane.#tab.ariaCurrent = "false";
tabPlane.#page.classList.add("d-none");
tabPlane.#page.ariaCurrent = "false";
}
this.#tab.classList.add("active");
this.#tab.ariaCurrent = "page";
this.#page.classList.remove("d-none");
this.#page.ariaCurrent = "page";
}
}
/**
* The month tab plane.
*
* @private
*/
class MonthTab extends TabPlane {
/**
* The month chooser.
* @type {tempusDominus.TempusDominus}
*/
#monthChooser
/**
* Constructs a tab plane.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
const monthChooser = document.getElementById(this.prefix + "-chooser");
if (monthChooser !== null) {
let start = monthChooser.dataset.start;
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
restrictions: {
minDate: start,
},
display: {
inline: true,
components: {
date: false,
clock: false,
},
},
defaultDate: monthChooser.dataset.default,
});
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
const date = e.detail.date;
const year = date.year;
const month = date.month + 1;
const period = month < 10? year + "-0" + month: year + "-" + month;
window.location = chooser.modal.dataset.urlTemplate
.replaceAll("PERIOD", period);
});
}
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "month";
}
}
/**
* The year tab plane.
*
* @private
*/
class YearTab extends TabPlane {
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "year";
}
}
/**
* The day tab plane.
*
* @private
*/
class DayTab extends TabPlane {
/**
* The day input
* @type {HTMLInputElement}
*/
#date;
/**
* The error of the date input
* @type {HTMLDivElement}
*/
#dateError;
/**
* Constructs a tab plane.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
this.#date = document.getElementById(this.prefix + "-date");
this.#dateError = document.getElementById(this.prefix + "-date-error");
if (this.#date !== null) {
this.#date.onchange = () => {
if (this.#validateDate()) {
window.location = chooser.modal.dataset.urlTemplate
.replaceAll("PERIOD", this.#date.value);
}
};
}
}
/**
* Validates the date.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateDate() {
if (this.#date.value === "") {
this.#date.classList.add("is-invalid");
this.#dateError.innerText = A_("Please fill in the date.");
return false;
}
if (this.#date.value < this.#date.min) {
this.#date.classList.add("is-invalid");
this.#dateError.innerText = A_("The date is too early.");
return false;
}
this.#date.classList.remove("is-invalid");
this.#dateError.innerText = "";
return true;
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "day";
}
}
/**
* The custom tab plane.
*
* @private
*/
class CustomTab extends TabPlane {
/**
* The start of the period
* @type {HTMLInputElement}
*/
#start;
/**
* The error of the start
* @type {HTMLDivElement}
*/
#startError;
/**
* The end of the period
* @type {HTMLInputElement}
*/
#end;
/**
* The error of the end
* @type {HTMLDivElement}
*/
#endError;
/**
* The confirm button
* @type {HTMLButtonElement}
*/
#conform;
/**
* Constructs a tab plane.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
this.#start = document.getElementById(this.prefix + "-start");
this.#startError = document.getElementById(this.prefix + "-start-error");
this.#end = document.getElementById(this.prefix + "-end");
this.#endError = document.getElementById(this.prefix + "-end-error");
this.#conform = document.getElementById(this.prefix + "-confirm");
if (this.#start !== null) {
this.#start.onchange = () => {
if (this.#validateStart()) {
this.#end.min = this.#start.value;
}
};
this.#end.onchange = () => {
if (this.#validateEnd()) {
this.#start.max = this.#end.value;
}
};
this.#conform.onclick = () => {
let isValid = true;
isValid = this.#validateStart() && isValid;
isValid = this.#validateEnd() && isValid;
if (isValid) {
window.location = chooser.modal.dataset.urlTemplate
.replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
}
};
}
}
/**
* Validates the start of the period.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
#validateStart() {
if (this.#start.value === "") {
this.#start.classList.add("is-invalid");
this.#startError.innerText = A_("Please fill in the start date.");
return false;
}
if (this.#start.value < this.#start.min) {
this.#start.classList.add("is-invalid");
this.#startError.innerText = A_("The start date is too early.");
return false;
}
if (this.#start.value > this.#start.max) {
this.#start.classList.add("is-invalid");
this.#startError.innerText = A_("The start date cannot be beyond the end date.");
return false;
}
this.#start.classList.remove("is-invalid");
this.#startError.innerText = "";
return true;
}
/**
* Validates the end of the period.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
#validateEnd() {
this.#end.value = this.#end.value.trim();
if (this.#end.value === "") {
this.#end.classList.add("is-invalid");
this.#endError.innerText = A_("Please fill in the end date.");
return false;
}
if (this.#end.value < this.#end.min) {
this.#end.classList.add("is-invalid");
this.#endError.innerText = A_("The end date cannot be beyond the start date.");
return false;
}
this.#end.classList.remove("is-invalid");
this.#endError.innerText = "";
return true;
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "custom";
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -20,12 +20,13 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/26
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const onReorder = () => {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");

View File

@ -14,50 +14,31 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The template filters and globals for the transaction management.
"""The template filters.
"""
import typing as t
from datetime import date, timedelta
from decimal import Decimal
from html import escape
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
urlunparse
from flask import request, current_app
from flask_babel import get_locale
from accounting.locale import gettext
from accounting.models import Currency
def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified.
:param uri: The URI.
:return: The result URL, optionally with the transaction type added.
"""
if "as" not in request.args:
return uri
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "next"]
params.append(("as", request.args["as"]))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def format_amount(value: Decimal | None) -> str:
def format_amount(value: Decimal | None) -> str | None:
"""Formats an amount for readability.
:param value: The amount.
:return: The formatted amount text.
"""
if value is None or value == 0:
if value is None:
return None
if value == 0:
return "-"
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return "{:,}".format(whole) + str(frac)[1:]
return "{:,}".format(whole) + str(abs(frac))[1:]
def format_date(value: date) -> str:
@ -90,30 +71,11 @@ def format_date(value: date) -> str:
return "{}/{}({})".format(value.month, value.day, weekday)
def text2html(value: str) -> str:
"""Converts plain text into HTML.
def default(value: t.Any, default_value: t.Any = "") -> t.Any:
"""Returns the default value if the given value is None.
:param value: The plain text.
:return: The HTML.
:param value: The value.
:param default_value: The default value when the given value is None.
:return: The value, or the default value if the given value is None.
"""
s: str = escape(value)
s = s.replace("\n", "<br>")
s = s.replace(" ", " &nbsp;")
return s
def currency_options() -> str:
"""Returns the currency options.
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
def default_currency_code() -> str:
"""Returns the default currency code.
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")
return default_value if value is None else value

View File

@ -0,0 +1,39 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The template globals for the transaction management.
"""
from flask import current_app
from accounting.models import Currency
def currency_options() -> str:
"""Returns the currency options.
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
def default_currency_code() -> str:
"""Returns the default currency code.
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")

View File

@ -59,8 +59,8 @@ First written: 2023/1/31
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog">
@ -85,9 +85,9 @@ First written: 2023/1/31
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_pay_off_needed %}
{% if obj.is_need_offset %}
<div>
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
</div>
{% endif %}
<div class="small text-secondary fst-italic">

View File

@ -27,7 +27,7 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group btn-actions mb-3">
<div class="btn-group mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
@ -36,14 +36,14 @@ First written: 2023/2/1
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
<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">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
<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>
<div id="accounting-base-content">
<div id="accounting-base">
{% if form.base_code.data %}
{% if form.base_code.errors %}
{{ A_("(Unknown)") }}
@ -53,19 +53,19 @@ First written: 2023/2/1
{% endif %}
</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 class="form-floating mb-3">
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.data|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div>
<div class="form-check form-switch mb-3">
<input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-pay-off-needed">
{{ A_("The entries in the account need pay-off.") }}
<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-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-need-offset">
{{ A_("The entries in the account need offset.") }}
</label>
</div>
@ -99,21 +99,21 @@ First written: 2023/2/1
</label>
</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 %}
<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 }}
</li>
{% endfor %}
</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 class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
{% 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 %}
<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 %}
</div>
</div>

View File

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

View File

@ -40,8 +40,8 @@ First written: 2023/2/2
{% if base.accounts|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
{% for account in base.accounts|sort(attribute="no") %}

View File

@ -21,17 +21,17 @@ First written: 2023/1/26
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search" class="accounting-search-label">
<div class="mb-2 accounting-toolbar">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
</form>

View File

@ -55,8 +55,8 @@ First written: 2023/2/6
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog">

View File

@ -27,7 +27,7 @@ First written: 2023/2/6
{% block content %}
<div class="btn-group btn-actions mb-3">
<div class="btn-group mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
@ -36,17 +36,17 @@ First written: 2023/2/6
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ form.code.data|accounting_default }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ form.name.data|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
</div>

View File

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

View File

@ -28,14 +28,14 @@ First written: 2023/1/26
</span>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}">
<i class="fa-solid fa-receipt"></i>
{{ A_("Transactions") }}
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
<i class="fa-solid fa-book"></i>
{{ A_("Reports") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-list"></i>
<i class="fa-solid fa-clipboard"></i>
{{ A_("Accounts") }}
</a>
</li>

View File

@ -0,0 +1,113 @@
{#
The Mia! Accounting Flask Project
income-statement.html: The income statement
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/7
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
</div>
<div class="row accounting-report-table accounting-balance-sheet-table">
<div class="col-sm-6">
{% if report.assets.subsections %}
{% with section = report.assets %}
{% include "accounting/report/include/balance-sheet-section.html" %}
{% endwith %}
<div class="d-md-none d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-total">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.assets.total < 0 %} text-danger {% endif %}">{{ report.assets.total|accounting_report_format_amount }}</div>
</div>
{% endif %}
</div>
<div class="col-sm-6">
{% if report.liabilities.subsections %}
{% with section = report.liabilities %}
{% include "accounting/report/include/balance-sheet-section.html" %}
{% endwith %}
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.liabilities.total < 0 %} text-danger {% endif %}">{{ report.liabilities.total|accounting_report_format_amount }}</div>
</div>
{% endif %}
{% if report.owner_s_equity.subsections %}
{% with section = report.owner_s_equity %}
{% include "accounting/report/include/balance-sheet-section.html" %}
{% endwith %}
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ report.owner_s_equity.total|accounting_report_format_amount }}</div>
</div>
{% endif %}
<div class="d-md-none d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-total">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.liabilities.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
</div>
</div>
</div>
<div class="row d-none d-md-flex">
<div class="col-sm-6">
<div class="d-flex justify-content-between accounting-balance-sheet-total">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.assets.total < 0 %} text-danger {% endif %}">{{ report.assets.total|accounting_report_format_amount }}</div>
</div>
</div>
<div class="col-sm-6">
<div class="d-flex justify-content-between accounting-balance-sheet-total">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.liabilities.total + report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
</div>
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

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

View File

@ -0,0 +1,43 @@
{#
The Mia! Accounting Flask Project
balance-sheet-section.html: A section in the balance sheet.
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/8
#}
<div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ section.title.title|title }}</div>
</div>
<div class="accounting-report-table-body">
{% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>
{% endfor %}
{% endfor %}
</div>

View File

@ -0,0 +1,27 @@
{#
The Mia! Accounting Flask Project
income-expenses-row-desktop.html: The row in the income and expenses log for the desktop computers
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/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.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>

View File

@ -0,0 +1,50 @@
{#
The Mia! Accounting Flask Project
income-expenses-row-mobile.html: The row in the income and expenses log for the mobile devices
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/5
#}
<div>
{% if entry.date or entry.account %}
<div class="text-muted small">
{% if entry.date %}
{{ entry.date|accounting_format_date }}
{% endif %}
{% if entry.account %}
{{ entry.account.title|title }}
{% endif %}
</div>
{% endif %}
{% if entry.summary %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div class="text-nowrap">
{% if entry.income %}
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
{% endif %}
{% if entry.expense %}
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
{% endif %}
{% if entry.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
{% else %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
{% endif %}
</div>

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
ledger-row-desktop.html: The row in the ledger for the desktop computers
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/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
{% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% endif %}

View File

@ -0,0 +1,43 @@
{#
The Mia! Accounting Flask Project
ledger-row-mobile.html: The row in the ledger for the mobile devices
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/5
#}
<div>
{% if entry.date %}
<div class="text-muted small">
{{ entry.date|accounting_format_date }}
</div>
{% endif %}
{% if entry.summary %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>
{% if entry.debit %}
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
{% endif %}
{% if entry.credit %}
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
{% endif %}
{% if report.account.is_real %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
{% endif %}
</div>

View File

@ -0,0 +1,150 @@
{#
The Mia! Accounting Flask Project
period-chooser.html: The period chooser
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/4
#}
<div id="accounting-period-chooser-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-period-chooser-modal-label" aria-hidden="true" data-url-template="{{ report.period_chooser.url_template }}">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-period-chooser-modal-label">{{ A_("Period Chooser") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
{# Tab navigation #}
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-period-chooser-month-tab" class="nav-link {% if report.period.is_type_month %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
{{ A_("Month") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-period-chooser-year-tab" class="nav-link {% if report.period.is_a_year %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
{{ A_("Year") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-period-chooser-day-tab" class="nav-link {% if report.period.is_a_day %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
{{ A_("Day") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-period-chooser-custom-tab" class="nav-link {% if report.period.is_type_arbitrary %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
{{ A_("Custom") }}
</span>
</li>
</ul>
{# The month periods #}
<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>
<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>
{% 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_("Last month") }}
</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_("Since last month") }}
</a>
{% endif %}
</div>
{% if report.period_chooser.has_data %}
<div id="accounting-period-chooser-month-chooser" class="mt-3" data-start="{{ report.period_chooser.data_start }}" data-default="{{ report.period.start|accounting_default(report.period_chooser.data_start) }}"></div>
{% endif %}
</div>
{# 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">
<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>
{% 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_("Last year") }}
</a>
{% endif %}
{% if report.period_chooser.available_years %}
<ul class="nav nav-pills mt-3">
{% for year in report.period_chooser.available_years %}
<li class="nav-item">
<a class="nav-link {% if report.period.is_year(year) %} active {% endif %}" href="{{ report.period_chooser.year_url(year) }}">{{ year }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{# The day periods #}
<div id="accounting-period-chooser-day-page" {% if report.period.is_a_day %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
<div>
<a class="btn {% if report.period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.today_url }}">
{{ A_("Today") }}
</a>
{% if report.period_chooser.has_yesterday %}
<a class="btn {% if report.period.is_yesterday %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.yesterday_url }}">
{{ A_("Yesterday") }}
</a>
{% endif %}
</div>
{% if report.period_chooser.has_data %}
<div class="mt-3">
<div class="form-floating mb-3">
<input id="accounting-period-chooser-day-date" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" required="required">
<label for="accounting-period-chooser-day-date" class="form-label">{{ A_("Date") }}</label>
<div id="accounting-period-chooser-day-date-error" class="invalid-feedback"></div>
</div>
</div>
{% endif %}
</div>
{# The custom periods #}
<div id="accounting-period-chooser-custom-page" {% if report.period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
<div>
<a class="btn {% if report.period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.all_url }}">
{{ A_("All") }}
</a>
</div>
{% if report.period_chooser.has_data %}
<div class="mt-3">
<div class="form-floating mb-3">
<input id="accounting-period-chooser-custom-start" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" max="{{ report.period.end }}" required="required">
<label for="accounting-period-chooser-custom-start" class="form-label">{{ A_("From") }}</label>
<div id="accounting-period-chooser-custom-start-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-period-chooser-custom-end" class="form-control" type="date" value="{{ report.period.end|accounting_default }}" min="{{ report.period.start }}" required="required">
<label for="accounting-period-chooser-custom-end" class="form-label">{{ A_("To") }}</label>
<div id="accounting-period-chooser-custom-end-error" class="invalid-feedback"></div>
</div>
<div>
<button id="accounting-period-chooser-custom-confirm" class="btn btn-primary" type="submit">
{{ A_("Confirm") }}
</button>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,43 @@
{#
The Mia! Accounting Flask Project
search-modal.html: The search modal
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/8
#}
<form action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label">
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-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-search-modal-label">{{ A_("Search") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="form-floating">
<input id="accounting-search-modal-search" class="form-control" type="text" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-modal-search">{{ A_("Search") }}</label>
</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_("Search") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,130 @@
{#
The Mia! Accounting Flask Project
toolbar-buttons.html: The toolbar buttons on the report
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/8
#}
{% if accounting_can_edit() %}
<div class="btn-group d-none d-md-flex" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
<span class="d-none d-md-inline">{{ A_("New") }}</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-book"></i>
<span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Report") }}">
{% for report in report.report_chooser %}
<li>
<a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">
<i class="{{ report.fa_icon }}"></i>
{{ report.title }}
</a>
</li>
{% endfor %}
<li>
<span class="dropdown-item {% if report.report_chooser.is_search %} active {% endif %} accounting-clickable" data-bs-toggle="modal" data-bs-target="#accounting-search-modal">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</span>
</li>
</ul>
</div>
{% if use_currency_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
<span class="d-none d-md-inline">{{ report.currency.name|title }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Currency") }}">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if use_account_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
<span class="d-none d-md-inline">{{ report.account.title|title }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Account") }}">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if use_period_chooser %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
<span class="d-none d-md-inline">{{ report.period.desc|title }}</span>
</button>
{% endif %}
{% if report.has_data %}
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
</a>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-download"></i>
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
</button>
{% endif %}
{% if use_search %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
</form>
{% endif %}

View File

@ -0,0 +1,116 @@
{#
The Mia! Accounting Flask Project
income-expenses.html: The income and expenses log
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/5
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_account_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}
{% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-income-expenses-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Account") }}</div>
<div>{{ A_("Summary") }}</div>
<div class="accounting-amount">{{ A_("Income") }}</div>
<div class="accounting-amount">{{ A_("Expense") }}</div>
<div class="accounting-amount">{{ A_("Balance") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="accounting-report-table-row">
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
</a>
{% endfor %}
</div>
{% if report.total %}
{% with entry = report.total %}
<div class="accounting-report-table-footer">
<div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
</div>
</div>
{% endwith %}
{% endif %}
</div>
<div class="list-group d-md-none">
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</a>
{% endfor %}
{% if report.total %}
{% with entry = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,99 @@
{#
The Mia! Accounting Flask Project
income-statement.html: The income statement
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/7
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
</div>
<div class="accounting-report-table accounting-income-statement-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div class="accounting-amount">{{ A_("Amount") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for section in report.sections %}
<div class="accounting-report-table-row accounting-income-statement-section">
<div>
<span class="d-none d-md-inline">{{ section.title.code }}</span>
{{ section.title.title|title }}
</div>
</div>
{% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-income-statement-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>
{% endfor %}
<div class="accounting-report-table-row accounting-income-statement-subtotal">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if subsection.total < 0 %} text-danger {% endif %}">{{ subsection.total|accounting_report_format_amount }}</div>
</div>
{% endfor %}
<div class="accounting-report-table-row accounting-income-statement-total">
<div>{{ section.accumulated.title|title }}</div>
<div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,106 @@
{#
The Mia! Accounting Flask Project
journal.html: The journal
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/4
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}
{% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-journal-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Currency") }}</div>
<div>{{ A_("Account") }}</div>
<div>{{ A_("Summary") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div>
<div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
{{ entry.account.title|title }}
</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
</a>
{% endfor %}
</div>
</div>
<div class="list-group d-md-none">
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }}
{{ entry.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
{% endif %}
</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,119 @@
{#
The Mia! Accounting Flask Project
ledger.html: The ledger
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/5
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_account_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}
{% endwith %}
<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-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Summary") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
{% if report.account.is_real %}
<div class="accounting-amount">{{ A_("Balance") }}</div>
{% endif %}
</div>
</div>
<div class="accounting-report-table-body">
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="accounting-report-table-row">
{% include "accounting/report/include/ledger-row-desktop.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-desktop.html" %}
</a>
{% endfor %}
</div>
{% if report.total %}
{% with entry = report.total %}
<div class="accounting-report-table-footer">
<div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
{% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>
<div class="list-group d-md-none">
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-mobile.html" %}
</a>
{% endfor %}
{% if report.total %}
{% with entry = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,103 @@
{#
The Mia! Accounting Flask Project
search.html: The search result
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/8
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_search = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}
{% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-journal-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Currency") }}</div>
<div>{{ A_("Account") }}</div>
<div>{{ A_("Summary") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div>
<div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
{{ entry.account.title|title }}
</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
</a>
{% endfor %}
</div>
</div>
<div class="list-group d-md-none">
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }}
{{ entry.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
{% endif %}
</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,85 @@
{#
The Mia! Accounting Flask Project
trial-balance.html: The trial balance
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/5
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
</div>
<div class="accounting-report-table accounting-trial-balance-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Account") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for account in report.accounts %}
<a class="accounting-report-table-row" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
</a>
{% endfor %}
</div>
<div class="accounting-report-table-footer">
<div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ report.total.debit|accounting_format_amount }}</div>
<div class="accounting-amount">{{ report.total.credit|accounting_format_amount }}</div>
</div>
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block 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 %}

View File

@ -21,6 +21,13 @@ First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
</a>
{% endblock %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
@ -28,23 +35,13 @@ First written: 2023/2/26
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
{% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>

View File

@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo
Author: imacat@mail.imacat.idv.tw (imacat)
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 }}-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 class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<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() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<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>
<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>
</button>
</div>
@ -50,14 +51,22 @@ First written: 2023/2/25
entry_index = loop.index,
entry_id = entry_form.eid.data,
only_one_entry_form = debit_forms|length == 1,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
@ -70,7 +79,7 @@ First written: 2023/2/25
</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-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -29,22 +29,29 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
debit_total = currency_form.form.debit_total|accounting_format_amount %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
currency_code_data = accounting_default_currency_code(),
debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

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