281 Commits

Author SHA1 Message Date
1224d6f83e Added the CSV_MIME constant to test_report.py to simplify the ReportTestCase test case. 2023-04-09 12:09:52 +08:00
3a8618f7c3 Fixed the csv_download function when downloading data with non-US-ASCII filenames in the "accounting.report.utils.csv_export" module. 2023-04-09 12:07:31 +08:00
5d87205659 Changed the data in the ReportTestData class to be non-US-ASCII. 2023-04-09 11:55:15 +08:00
04de4f5c5e Merged testlib_offset.py into testlib.py. 2023-04-09 11:46:55 +08:00
f8ea863b80 Moved the add_journal_entry and match_journal_entry_detail functions from testlib_journal_entry.py to testlib.py. They are used by everyone, and testlib_journal_entry.py is only for test_journal_entry.py to shorten the code in one single file. 2023-04-09 11:46:55 +08:00
5ae0d03b32 Revised the imports in testlib_journal_entry.py. 2023-04-09 11:46:55 +08:00
a9a3ad5871 Fixed the data type of the original line item ID in the forms in the OffsetTestCase test case. 2023-04-09 11:46:55 +08:00
5edc95afce Moved the TestData class from testlib_offset.py to test_offset.py, and renamed it to OffsetTestData. It is only used in test_offset.py now. 2023-04-09 11:46:55 +08:00
943ace6fc7 Added ReportTestData as the test data for the ReportTestCase test case. 2023-04-09 11:46:46 +08:00
a63bc977e9 Added the _add_simple_journal_entry method to the BaseTestData class in testlib_offset.py to simplify the code. 2023-04-09 10:50:50 +08:00
dabe6ddbca Renamed the _set_is_need_offset method to _set_need_offset in the BaseTestData class in testlib_offset.py. 2023-04-09 10:42:18 +08:00
f47e9b3150 Renamed the CurrencyData class to JournalEntryCurrencyData in testlib_offset.py, to be clear. 2023-04-09 10:42:18 +08:00
bb5383febe Removed the test data from the OptionTestCase test case. It does not need data. 2023-04-09 10:42:18 +08:00
87f9063ceb Added the BaseTestData class in testlib_offset.py to simplify the test data, and changed the TestData, DifferentTestData, and SameTestData classes to its subclasses. 2023-04-09 10:31:44 +08:00
51f0185bcf Added to test the search in the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
7ca08d6cc8 Added to test the CSV download in the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
c8e9e562be Fixed a URL in the test_nobody test of the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
ba43bd7e90 Simplify the URL of the default reports. 2023-04-09 10:08:23 +08:00
4e550413ba Revised the styles for blueprints to specify the URL, for consistency in the base account, account, currency, and journal entry management. 2023-04-09 10:08:22 +08:00
59a3cbb472 Added the ReportTestCase test case. 2023-04-09 10:08:22 +08:00
d1b64d069e Added the test_empty_db test to the UnmatchedOffsetTestCase test case. 2023-04-09 10:08:11 +08:00
d823d3254f Fixed the date in test_unmatched_offset.py. 2023-04-09 10:07:56 +08:00
5e9a2fb0c3 Renamed test_offset_matcher.py to test_unmatched_offset.py, and the OffsetMatcherTestCase test case to UnmatchedOffsetTestCase. 2023-04-09 10:06:53 +08:00
3f2e659ba5 Added the test_nobody, test_viewer, and test_editor tests to test the permissions in the OffsetMatcherTestCase test case. 2023-04-09 10:06:33 +08:00
9f7bb6b9de Added match_uri to the tests of the OffsetMatcherTestCase test case, for readability. 2023-04-09 08:25:34 +08:00
6857164702 Added the PREFIX constant to simplify the OffsetMatcherTestCase test case. 2023-04-09 08:22:25 +08:00
6bac76be64 Fixed an error in the formatted string in the translation. 2023-04-09 01:41:42 +08:00
370d2668e5 Advanced to version 1.1.0. 2023-04-09 00:48:57 +08:00
5e3e695e62 Updated the Sphinx documentation. 2023-04-09 00:41:14 +08:00
510d369e9c Updated the translation. 2023-04-09 00:39:46 +08:00
b65cae9252 Added the OffsetMatcherTestCase test case. 2023-04-09 00:39:46 +08:00
285c12406b Revised the property names in the TestData class in testlib_offset.py. 2023-04-09 00:39:46 +08:00
df240472a4 Changed the permission to the offset matcher so that editors can use it. 2023-04-09 00:39:45 +08:00
1218b224fc Renamed the "accounting.unmatched_offset.forms" module to "accounting.utils.offset_matcher". 2023-04-09 00:39:45 +08:00
79689ac0e5 Revised the unapplied original line item report to mark matched offsets for administrators when there are unmatched offsets. 2023-04-09 00:39:45 +08:00
1660e66766 Revised the background color of the report tables, for better look on non-white backgrounds. 2023-04-09 00:39:45 +08:00
12d00c9c7d Added the unmatched offset list and the offset matcher. 2023-04-09 00:39:11 +08:00
428018e4a9 Added the match pseudo property to the JournalEntryLineItem data model. 2023-04-08 18:12:57 +08:00
a8f318b0bb Reordered the methods in the JournalEntryLineItem data model. 2023-04-08 18:12:57 +08:00
a3507494e5 Added the refundable deposit accounts to the default list of accounts that need offset in the accounting-init-accounts console command. 2023-04-08 18:12:57 +08:00
3aa6c8d6f6 Removed the empty value in the __is_need_offset function in the "accounting.account.commands" console.command. 2023-04-08 18:12:56 +08:00
052b62cdd4 Moved the __query_line_items method in the UnappliedOriginalLineItems report to the new "accounting.utils.unapplied" module, to share this query. 2023-04-08 18:12:56 +08:00
3728a4037d Renamed the UnappliedAccountConverter path converter to NeedOffsetAccountConverter. 2023-04-08 18:12:56 +08:00
6eee17d44f Added the account list as the default page for the unapplied original line items. 2023-04-08 18:12:55 +08:00
e5cc2b5a2f Added the "count" pseudo property to the Account data model. 2023-04-08 18:12:55 +08:00
ac3b5523b1 Fixed the documentation of the default_currency and default_ie_account pseudo property in the Options class. 2023-04-08 18:12:55 +08:00
5af6fd9619 Moved the "accounting.journal_entry.utils.offset_alias" module to "accounting.utils.offset_alias". 2023-04-08 18:12:55 +08:00
71a20cba29 Replaced the "default_currency_text" pseudo property with the "default_currency" pseudo property in the Options class. 2023-04-08 18:12:54 +08:00
4a4cf1ea40 Removed the redundant "default_ie_account_code_text" pseudo property from the Options class. 2023-04-08 18:12:54 +08:00
e9824808ec Added the UnappliedAccountConverter path converter to only allow the accounts that need offsets. 2023-04-08 18:12:54 +08:00
c984d2d596 Renamed the IncomeExpensesAccountConverter path converter to CurrentAccountConverter. 2023-04-08 18:12:54 +08:00
720e77c814 Fixed the documentation of the PeriodConverter and IncomeExpensesAccountConverter path converters. 2023-04-08 18:12:54 +08:00
0f0412827d Added the unapplied original line item report. 2023-04-08 18:12:45 +08:00
3a0e978f76 Removed an unused import from the "accounting.journal_entry.forms.line_item" module. 2023-04-08 00:44:13 +08:00
8c10d42d7b Added documentation to the currency and account parameters of the CSVRow class, and the pagination parameter of the PageParams class in the "accounting.report.reports.journal" module. 2023-04-08 00:44:13 +08:00
04ec51afbe Changed the "offsets" relationship to a pseudo property, to apply the correct but complex ordering rules. 2023-04-07 16:04:54 +08:00
fe7a8842ce Fixed the query in the JournalEntryConverter converter. 2023-04-07 15:31:06 +08:00
66daa5c42c Fixed the query in the KeepAccountWhenHavingOffset validator. 2023-04-07 15:29:17 +08:00
27fb44937d Fixed the incorrect query in the "offsets" pseudo property of the LineItemForm form. 2023-04-07 15:11:04 +08:00
7026ed3a65 Fixed the order of the items in the "offsets" pseudo property of the LineItemForm form. 2023-04-07 15:01:22 +08:00
fdd3e93778 Fixed the net balance in the line items in the journal entry detail. 2023-04-07 14:57:24 +08:00
def7559457 Fixed the #filterOptions in the JavaScript JournalEntryAccountSelector to show the "more" option when there is no matches, but it is not showing all the accounts. 2023-04-07 12:34:24 +08:00
7905820d68 Revised the imports in the "accounting.base_account.views" and "accounting.currency.views" modules. 2023-04-06 16:09:36 +08:00
7ae332c975 Moved the "Test Site and Live Demonstration" section to the front of the documentation. 2023-04-06 10:00:24 +08:00
86c5b91697 Advanced to version 1.0.1. 2023-04-06 08:43:14 +08:00
9168840e64 Fixed an error in the example configuration. 2023-04-06 08:38:39 +08:00
21b9cfa8b8 Revised the documentation. 2023-04-06 08:31:19 +08:00
b0b3b3acb1 Moved the history section out from README.rst and intro.rst, to the new history.rst. 2023-04-06 08:21:32 +08:00
cb1d254cf0 Advanced to version 1.0.0. Hooray! 2023-04-06 02:55:19 +08:00
eb9ad57e72 Updated the translation. 2023-04-06 02:55:17 +08:00
ec26f8ef4d Added the documentation. 2023-04-06 02:54:45 +08:00
7ed29115ed Revised the inclusion in the base template of the test site. 2023-04-06 02:01:05 +08:00
95955197ac Updated the copyright year in pyproject.toml. 2023-04-05 22:50:54 +08:00
d5a0f79e4b Revised the Read the Docs configuration, and removed the redundant requirements.txt for Read the Docs. 2023-04-05 22:01:53 +08:00
6aa655aa64 Replaced setup.cfg with pyproject.toml for the package settings, and rewrote the packaging rules in MANIFEST.in. 2023-04-05 19:49:52 +08:00
6e532af26e Added the Read the Docs documentation link to README.rst. 2023-04-05 14:25:33 +08:00
fa1818d124 Added the Read the Docs configuration file. 2023-04-05 14:12:46 +08:00
f21ecc2aa9 Added requirements.txt for Read the Docs. 2023-04-05 14:07:37 +08:00
5ae1ab95ae Advanced to version 0.11.1. 2023-04-05 13:00:46 +08:00
7a5b3b78fc Removed the rows with zero balance from the income statement. 2023-04-05 12:59:50 +08:00
7df4051452 Removed the rows with zero balance from the trial balance. 2023-04-05 12:56:28 +08:00
85084c68fd Removed the rows with zero balance from the balance sheet. 2023-04-05 12:29:58 +08:00
0185c16654 Advanced to version 0.11.0. 2023-04-05 09:59:23 +08:00
7dd007f3cf Revised README.rst. 2023-04-05 09:57:34 +08:00
38b8a028d5 Reversed the original line items in the original line item selector. 2023-04-05 09:25:41 +08:00
213981a8b2 Revised the style of the buttons in the description editor, to avoid overwhelming the modal when there are too many buttons. 2023-04-05 09:11:27 +08:00
a4d1789b58 Moved the income and expenses log to the first item of the report chooser. 2023-04-05 08:15:16 +08:00
91620d7db2 Revised the init_app function in the "accounting" module. 2023-04-05 08:07:17 +08:00
02fcabb0ce Updated the URI of the reports to be the default views of the application. 2023-04-05 08:06:00 +08:00
4c2dcc5070 Renamed the project from "Mia! Accounting Flask" to "Mia! Accounting". 2023-04-04 18:26:54 +08:00
c9166fda4d Fixed the order in the get_selectable_original_line_items function in the "accounting.journal_entry.utils.original_line_item" module. 2023-04-04 10:54:43 +08:00
3a0f0873e2 Added documentation to the bp, babel_js, csrf, and db variables in the test site. 2023-04-03 22:18:58 +08:00
a17395b43e Advanced to version 0.10.0. 2023-04-03 22:08:02 +08:00
17c8d9d1a9 Revised the styles of the buttons of the suggested accounts in the description editor. 2023-04-03 22:07:56 +08:00
fa94cd407e Added the JavaScript setElementShown function in the journal entry form for readability. 2023-04-03 21:37:51 +08:00
9a704c8185 Revised the JavaScript account reorder code to avoid nested template literals, for readability. 2023-04-03 21:20:24 +08:00
8286c0c6d8 Revised the JavaScript MonthTab class in the period chooser to avoid nested template literals, for readability. 2023-04-03 21:19:48 +08:00
f7efacad75 Added the unauthorized method to the UserUtilityInterface interface, so that when the user has not logged in, the permission decorator can ask the user to log in instead of failing with HTTP 403 Forbidden. 2023-04-03 19:50:47 +08:00
9263ae0274 Changed the "account" property to private as "__account" in the DescriptionAccount class. 2023-04-03 19:50:47 +08:00
78a9d7794c Revised the JavaScript OriginalLineItem class to store the form instead of the selector. The selector is only used in the constructor. 2023-04-03 19:50:47 +08:00
f3ae37a409 Removed the "#selector" attribute from the JavaScript RecurringAccount class. It is only used in the constructor. There is no need to hold a reference to it. 2023-04-03 19:50:47 +08:00
ddc1081252 Removed the "#selector" attribute from the JavaScript BaseAccountOption class. It is only used in the constructor. There is no need to hold a reference to it. 2023-04-03 19:50:46 +08:00
202d51a032 Removed the "#selector" attribute from the JavaScript JournalEntryAccountOption class. It is only used in the constructor. There is no need to hold a reference to it. 2023-04-03 19:50:46 +08:00
562bc47be7 Revised the saveDescription method of the JournalEntryLineItemEditor editor to also save the isAccountConfirmed status of the DescriptionEditor editor, so that when the user selected any suggested account other than the confirmed account, the confirmed account is released from the next edit. 2023-04-03 19:50:46 +08:00
f3d43a66cc Fixed the operator in the selectAccount method of the JavaScript DescriptionEditor editor. 2023-04-03 19:50:46 +08:00
c3fc6d9a87 Revised the onOpen method of the JavaScript DescriptionEditor editor, to clear the tab planes after the confirmed account is set, so that it works in an environment where the confirmed account is already set. 2023-04-03 19:50:46 +08:00
e1a0380628 Revised the saveDescription method of the JavaScript JournalEntryLineItemEditor to accept the description editor instead of the separated description and account values. 2023-04-03 19:50:46 +08:00
f2a2fcdd32 Revised the "#onDescriptionChange" method to also reset the selected account in the JavaScript DescriptionEditor editor. 2023-04-03 19:50:46 +08:00
ab29166f1e Renamed the "#reset" method to "#resetTabPlanes" in the JavaScript DescriptionEditor, to be clear. 2023-04-03 19:50:46 +08:00
8033921181 Revised the JavaScript DescriptionEditor class so that the #reset() method is triggered by the #onDescriptionChange event, but not the onOpen event, so that user-edited description updates also clear the tab planes. 2023-04-03 19:50:45 +08:00
08732c1e66 Renamed the description attribute to #descriptionInput, and added the description getter and setter to the JavaScript DescriptionEditor editor, to hide the actual implementation of the description input. 2023-04-03 19:50:45 +08:00
4adc464d3d Merged the saveDescriptionWithAccount into the saveDescription method in the JavaScript JournalEntryLineItemEditor class. 2023-04-03 19:12:06 +08:00
2f9d2e36cb Revised the parameters of the saveDescriptionWithAccount method of the JavaScript JournalEntryLineItemEditor class to accept an DescriptionEditorAccount instance instead of the individual account values. 2023-04-03 19:12:06 +08:00
5bb10bf6ba Added the JavaScript DescriptionEditorAccount, DescriptionEditorSuggestedAccount, and DescriptionEditorConfirmedAccount classes, and revised the DescriptionEditor editor to work with these class instances instead of the HTML elements, for simplicity and readability. 2023-04-03 19:12:06 +08:00
06e7b6ddff Added the missing "is_need_offset" property to the DescriptionAccount class. 2023-04-03 19:11:10 +08:00
20e1982984 Renamed the "accounting-is-need-offset" class to "accounting-account-is-need-offset" in the line item sub-form of the journal entry form, for consistency. 2023-04-02 22:29:27 +08:00
a70720be50 Renamed the #selectedAccount attribute to #selectedAccountButton, and the filterSuggestedAccounts, #selectSuggestedAccount, clearSuggestedAccounts, #initializeSuggestedAccounts, #selectAccount, #setConfirmedAccount, and #setSuggestedAccounts methods to filterSuggestedAccountButtons, #selectSuggestedAccountButton, clearSuggestedAccountButtons, #initializeSuggestedAccountButtons, #selectAccountButton, #setConfirmedAccountButton, and #setSuggestedAccountButtons, respectively, in the JavaScript DescriptionEditor class. 2023-04-02 22:16:29 +08:00
cb6de08152 Moved the JournalEntryAccount class from journal-entry-line-item-editor.js to journal-entry-form.js. 2023-04-01 22:42:58 +08:00
211821b4d7 Added the "confirmed account" to the description editor so that it does not override the user's selected account when the user specifically selected it or already confirmed it. 2023-04-01 18:05:48 +08:00
0faca49540 Revised the save method of the JavaScript LineItemSubForm class to update whether it needs offsetting, too. 2023-04-01 00:34:29 +08:00
14e79df571 Revised the line item sub-form to store the information whether it needs offsetting as a class instead of a dataset attribute, and store it in the account code input instead of the whole element, for simplicity and readability. 2023-04-01 00:29:04 +08:00
04fbb725d2 Revised the logic to save the account in the save method of the LineItemSubForm class, since when saving from the line item editor, the account is never null. 2023-04-01 00:19:32 +08:00
a1d6844e52 Replaced the accountCode and accountText getters with the account getter in the JavaScript LineItemSubForm class. 2023-04-01 00:14:47 +08:00
94391b02a6 Added the copy() method to the JavaScript JournalEntryAccount class, and replaced the accountCode and accountText fields with the account field in the OriginalLineItem class. 2023-03-31 23:54:56 +08:00
1cb8a7563e Added the JavaScript JournalEntryAccount class, and added the account field to the JournalEntryLineItemEditor class to replace the accountCode, accountText, and isNeedOffset fields. 2023-03-31 23:33:38 +08:00
63f0f28948 Prefix the classes in the JavaScript description editor with the "DescriptionEditor". 2023-03-27 07:22:36 +08:00
3431922f12 Removed an unused import from the "accounting.models" module. 2023-03-26 01:06:19 +08:00
d5a9e1af18 Removed an unnecessary "start" variable in the constructor of the JavaScript MonthTab class. 2023-03-25 08:37:17 +08:00
73f5d63f44 Replaced string concatenations with ES6 template literals. 2023-03-25 08:37:13 +08:00
bf2c7bb785 Advanced to version 0.9.1. 2023-03-24 09:16:54 +08:00
93ba086548 Simplified the code in the query_values pseudo property of the JournalEntryLineItem data model. 2023-03-24 09:15:29 +08:00
5c4f6017b8 Removed the redundant partial time in the query_values pseudo property of the JournalEntryLineItem data model. They are redundant since it is always partial match now. 2023-03-24 09:13:26 +08:00
cb16b2f0ff Updated the translation of the test site. 2023-03-24 08:53:35 +08:00
d2f11e8779 Replaced the "editor" and "editor2" accounts in the test site with "admin" and "editor", to be clear. 2023-03-24 08:53:35 +08:00
4ccaf01b3c Revised the template of the option detail to be visually different from the option edit form, to avoid confusion. 2023-03-24 08:52:58 +08:00
7c512b1c15 Revised the JavaScript DebitCreditSubForm to have a better visual effect when the line item editor is opened and closed with no line items. 2023-03-24 07:58:32 +08:00
dc432da398 Revised the coding style in the constructor of the JavaScript JournalEntryLineItemEditor class. 2023-03-24 07:49:58 +08:00
c8504bcbf5 Revised the #isQueryMatched method to match the current net balance instead of the net balance but the current form in the JavaScript OriginalLineItem class. 2023-03-24 07:47:41 +08:00
c865141583 Revised the #isQueryMatched method to always does partial match in the JavaScript OriginalLineItem class. Removed the full match from the query values. It is really wierd to type in the half with no match until you type the full term. It may create misunderstanding that there is no further match if you keep typing. 2023-03-24 07:38:17 +08:00
8c1ecd6eac Renamed the #isDebitCreditMatches and #isQueryMatches methods in the JavaScript OriginalLineItem class to #isDebitCreditMatched and #isQueryMatched, respectively. 2023-03-24 07:32:23 +08:00
e8e4100677 Revised the documentation of the JavaScript #isQueryMatches method of the OriginalLineItem class. 2023-03-24 07:31:13 +08:00
6a8773c531 Revised the code in the constructor of the JavaScript OriginalLineItemSelector class. 2023-03-24 07:30:09 +08:00
30e0c7682c Renamed the JavaScript AccountSelector class to JournalEntryAccountSelector, to avoid confusion. There is a RecurringAccountSelector in the option form now. 2023-03-24 07:27:52 +08:00
eb5a7bef7e Added the JavaScript AccountOption class to object-ize the account options in the journal entry form. 2023-03-24 07:23:56 +08:00
8a174d8847 Renamed the setBaseAccount method to saveBaseAccount in the JavaScript AccountForm form, for consistency. 2023-03-24 07:20:40 +08:00
7459afd63a Renamed the hasAnyMatched variable to isAnyMatched in the JavaScript #filterOptions method of the BaseAccountSelector, RecurringAccountSelector, and OriginalLineItemSelector classes. 2023-03-24 06:52:14 +08:00
a9afc385e9 Added the "baseCode" getter to the JavaScript AccountForm form, and removed the "baseCode" parameter from the onOpen method of the BaseAccountSelector class. It can retrieve the base code directly from the parent account form now. 2023-03-24 06:46:22 +08:00
a8be739ec7 Fixed the documentation of the JavaScript BaseAccountOption class. 2023-03-24 06:35:44 +08:00
0130bc58a9 Added prefix to the constructor of the BaseAccountSelector class, to simplify the code. 2023-03-24 00:37:59 +08:00
821059fa80 Added the JavaScript BaseAccountOption class to object-ize the base account options in the account form. 2023-03-24 00:35:50 +08:00
5b4f57d0b3 Removed a debugging log from the onOpen method of the RecurringAccountSelector class. 2023-03-24 00:27:37 +08:00
4bfac2d545 Removed an unused "noinspection JSValidateTypes" comment from the constructor of the JavaScript DebitCreditSubForm class. 2023-03-24 00:21:33 +08:00
f105f0cf7b Removed an orphan comment from the JavaScript RecurringTransactionTab class. 2023-03-24 00:20:59 +08:00
5e320729d7 Removed an excess blank line in testlib.py. 2023-03-23 17:30:38 +08:00
7515032082 Moved the Accounts shortcut from testlib_journal_entry.py to testlib.py. 2023-03-23 17:26:27 +08:00
361b18e411 Moved the duplicated NEXT_URI constant from test_account.py and testlib_journal_entry.py to testlib.py. 2023-03-23 17:22:57 +08:00
7d084e570e Revised the debit-credit content to have a better look when it is still empty. 2023-03-23 09:13:52 +08:00
cb397910f8 Added the "resetNo" method to the RecurringItemSubForm, CurrencySubForm, and LineItemSubForm forms to provide a simpler way to reset the order number, and removed the "elementId" getter and "no" setter. 2023-03-23 08:55:16 +08:00
5f8b0dec98 Renamed the JavaScript "lineItemIndex" property to "index" in the LineItemSubForm form. 2023-03-23 08:44:20 +08:00
8398d1e8bb Fixed a type error in the constructor of the JavaScript LineItemSubForm form. 2023-03-23 08:42:48 +08:00
562801692a Added the JavaScript setDeleteButtonShown method to the CurrencySubForm and LineItemSubForm forms, and hides the implementation of the delete buttons from outside. Changed the delete buttons to private. 2023-03-23 08:40:19 +08:00
faee1e61c6 Added the JavaScript elementId getter and no setter to the RecurringItemSubForm, CurrencySubForm, and LineItemSubForm forms, to hide the actual implementation of the element ID and order number. 2023-03-23 08:24:58 +08:00
57a4177037 Replaced the JavaScript getXXX methods with the "get XXX" getters. 2023-03-23 08:11:11 +08:00
fa1dedf207 Unified the documentation of the JavaScript prefix attribute. 2023-03-23 07:10:16 +08:00
7ed13dc0af Replaced the JavaScript prefix attributes that are only used in the class constructors with the prefix constant variables in the constructor. 2023-03-23 07:06:58 +08:00
52807c5322 Advanced to version 0.9.0. 2023-03-23 00:48:14 +08:00
231a71feea Updated the Sphinx documentation. 2023-03-23 00:47:04 +08:00
4902eecae0 Updated the translation. 2023-03-23 00:46:18 +08:00
889e4c058e Revised the option form to have a better look when there is no recurring expense and income. 2023-03-23 00:45:19 +08:00
7262a6cb42 Removed an unused ID in the form-recurring-item.html template. 2023-03-23 00:27:23 +08:00
c4ff4ecb3d Fixed the layout in the option detail when there is no recurring expenses or incomes. 2023-03-23 00:24:57 +08:00
2859f628ea Fixed the error finding the account in the default_ie_account_code_text pseudo property of the Options data model. 2023-03-23 00:21:31 +08:00
e0355b2af1 Revised the error message of the CurrentAccountExists and AccountNotCurrent validators. 2023-03-23 00:09:57 +08:00
b4d390c33a Renamed the isMatches method to isMatched in the JavaScript RecurringAccount class. 2023-03-23 00:00:39 +08:00
a4ab8a761c Renamed the "content" dataset to "text" in the base account selector of the account form, for consistency. 2023-03-22 23:56:37 +08:00
907ce6d06e Renamed the "content" dataset to "text" in the account selector of the journal entry form, for consistency. 2023-03-22 23:55:28 +08:00
7e1388735e Added the OptionTestCase test case. 2023-03-22 23:50:14 +08:00
6f773dd837 Added the ACCOUNT_REQUIRED validator to the account_code field of the RecurringExpenseForm and RecurringIncomeForm forms. 2023-03-22 23:25:20 +08:00
87fa5aa6bc Moved the ACCOUNT_REQUIRED validator from the "accounting.journal_entry.forms.line_item" module to the "accounting.forms" module, in order to share it. 2023-03-22 23:23:53 +08:00
35e05b3708 Revised the IsDebitAccount and IsCreditAccount validators to require the error message, and moved their default error messages to the DebitLineItemForm and CreditLineItemForm forms. 2023-03-22 23:19:52 +08:00
7ccc96bda0 Added the CurrentAccountExists and AccountNotCurrent validators to the default_ie_account_code field of the OptionForm form, to validate that the current account exists and is a current account. 2023-03-22 23:16:31 +08:00
283758ebe9 Revised the shortcut accounts in testlib_journal_entry.py. 2023-03-22 22:56:37 +08:00
b673c7aeaf Renamed the "default_currency" option to "default_currency_code". 2023-03-22 22:34:13 +08:00
0ad2ac53dd Added the "sql_condition" method to the CurrentAccount data model to simplify the queries. 2023-03-22 21:43:58 +08:00
7e90ec5a8f Replaced the "current_accounts" function with the "accounts" class method of the CurrentAccount data model. 2023-03-22 21:39:18 +08:00
7755365467 Revised the documentation of the CurrentAccount data model. 2023-03-22 21:36:07 +08:00
979eea606a Added the missing documentation to the account property of the CurrentAccount data model. 2023-03-22 20:40:43 +08:00
5a9e08f2c4 Moved the AccountExists, IsDebitAccount, and IsCreditAccount validators from the "accounting.journal_entry.forms.line_item" module to the "accounting.forms" module, to share it with the "accounting.option.forms" module. Removed the redundant AccountExists, IsExpenseAccount, and IsIncomeAccount validators from the "accounting.option.forms" module. 2023-03-22 20:37:53 +08:00
68c810d492 Renamed the debit and credit methods in the Account data model to selectable_debit and selectable_credit, to be clear. 2023-03-22 20:21:52 +08:00
5f88260507 Revised the elements in the option detail page for better layout. 2023-03-22 20:16:08 +08:00
779d89f8c4 Replaced the "clear" method with the "onOpen" method when the account is clicked in the RecurringItemEditor in the JavaScript RecurringAccountSelector class. 2023-03-22 20:09:41 +08:00
5d4bf4361b Revised the error messages of the NotStartPayableFromExpense and NotStartReceivableFromIncome validators. 2023-03-22 20:00:17 +08:00
10170d613d Fixed the debit and credit methods of the Account data model, removing the payable accounts that need offset from the debit accounts, and the receivable accounts that need offset from the credit accounts. They should not be selectable. 2023-03-22 19:54:27 +08:00
c885c08c37 Moved the "accounting.option.options" module to "accounting.utils.options", because it is meant to shared by other submodules. 2023-03-22 19:47:37 +08:00
e2a4340f2a Revised the imports in the "accounting.option.views" module. 2023-03-22 19:43:10 +08:00
9728ff30e0 Renamed the IsDebitAccount, IsCreditAccount, NotStartPayableFromDebit, and NotStartReceivableFromCredit validators to IsExpenseAccount, IsIncomeAccount, NotStartPayableFromExpense, and NotStartReceivableFromIncome, respectively, in the "accounting.option.forms" module. 2023-03-22 19:41:54 +08:00
a4644ede5f Fixed and replaced the IsDebitAccount validator with the IsCreditAccount validator in the account_code field of the RecurringIncomeForm form. 2023-03-22 19:39:02 +08:00
8f477dd6f1 Added the all_errors pseudo property to the RecurringItemForm form, and applied it to the form-recurring-item.html template. 2023-03-22 19:37:20 +08:00
44ac53f15c Fixed and added the missing validation in the update_options route. 2023-03-22 19:33:21 +08:00
5edb5465c5 Fixed the incomes field of the RecurringForm form to use the RecurringIncomeForm form instead of the RecurringExpenseForm form as its sub-forms. 2023-03-22 19:29:42 +08:00
067afdb165 Fixed and moved the account_text pseudo property from the RecurringExpenseForm form to its base RecurringItemForm form. 2023-03-22 19:28:46 +08:00
37a4c26f86 Fixed the label in the option detail and option form. 2023-03-22 19:21:24 +08:00
89e43830b4 Fixed the __get_accounts method of the DescriptionEditor class not to do empty queries. 2023-03-22 19:17:25 +08:00
671dbfb692 Moved the CURRENCY_REQUIRED validator back from the "accounting.forms" module to the "accounting.journal_entry.forms.currency" module. It is not shared with other module anymore. 2023-03-22 19:14:51 +08:00
2014344d25 Revised to use its own error message for the DataRequired validator in the default_currency field of the OptionForm form. 2023-03-22 19:13:08 +08:00
f9c39709c8 Revised the text messages in the option forms. 2023-03-22 19:11:07 +08:00
b394c58ec6 Added support to sort the recurring items. 2023-03-22 19:01:02 +08:00
0af3e2785b Removed an unused import from the "accounting.option.forms" module. 2023-03-22 18:50:24 +08:00
7066f75e72 Added the read-only view for the options. 2023-03-22 16:08:16 +08:00
619540da49 Fixed the documentation of the form-recurring-expense-income.html template. 2023-03-22 15:47:19 +08:00
567004f7d9 Renamed IncomeExpensesAccount to CurrentAccount. 2023-03-22 15:42:44 +08:00
761d5a5824 Added the option management, and moved the configuration of the default currency, the default account for the income and expenses log, and the recurring expenses and incomes to the options. 2023-03-22 15:34:28 +08:00
fa3cdace7f Renamed the #validateForm method to #validate in the JavaScript AccountForm and CurrencyForm. 2023-03-22 11:22:46 +08:00
656762850c Moved the IncomeExpensesAccount data model from the "accounting.report.utils.ie_account" module to the "accounting.utils.ie_account" module. 2023-03-22 07:29:41 +08:00
e2325f08d0 Moved the CURRENCY_REQUIRED and CurrencyExists validators from the "accounting.journal_entry.forms.currency" module to the "accounting.forms" module. 2023-03-22 07:17:45 +08:00
855356084e Fixed the documentation of the can_view and can_edit functions in the "accounting.utils.permission" module. 2023-03-22 04:50:12 +08:00
7aaeb32a3d Added the missing "role=" to the "<a...></a>" links that act like buttons. 2023-03-22 02:35:07 +08:00
b376cf1580 Revised the toolbar layout so that it looks better with only one toolbar button on the mobile devices. 2023-03-22 02:28:58 +08:00
ccbdc779ac Restored the "Back" button on the toolbar for the mobile devices. It is still necessary, because the user may get lost in the navigation history. 2023-03-22 02:28:29 +08:00
61ee08fda2 Revised the date format in the journal entry order page, and removed the individual date in the page, as it is redundant. 2023-03-22 02:12:19 +08:00
d8afadda02 Advanced to version 0.8.0. 2023-03-22 01:52:24 +08:00
c8e1270d8f Updated the translation. 2023-03-22 01:50:18 +08:00
2a78799404 Revised the page to reorder the journal entries in a same day. 2023-03-22 01:47:11 +08:00
863d7a9368 Simplified the "can_delete" pseudo property of the JournalEntry data model. SQLAlchemy caches the query result. There is no need to cache the result again. 2023-03-22 01:02:09 +08:00
6fd37b21d9 Fixed so that the journal entries that has offset cannot be deleted. 2023-03-22 00:59:43 +08:00
bbf3ee3320 Added the limitation so that the default currency and the currencies in use cannot be deleted. 2023-03-22 00:37:39 +08:00
b60cc7902d Revised the test_delete test in the AccountTestCase test case. 2023-03-22 00:37:26 +08:00
623313b58a Renamed the constants to be upper-cased in test_account.py. 2023-03-22 00:37:26 +08:00
d0d2d77a2e Added the limitation so that essential accounts, like cash, and the accounts in use, cannot be deleted. 2023-03-22 00:37:26 +08:00
494faeffea Revised the toolbar of the reports to fit better in desktop browsers. 2023-03-21 23:16:47 +08:00
871a5fd1d8 Changed the "settings" button to "edit" in the account, currency, and journal entry detail pages. 2023-03-21 23:10:33 +08:00
e615ad2690 Revised the style of the toolbar buttons for better layout on mobile devices. Hid the "Back" button on mobile devices for better layout and saving spaces. 2023-03-21 23:07:05 +08:00
da92a0b42c Replaced the BABEL_DEFAULT_LOCALE configuration variable with the default_locale from the Flask-Babel instance, to get rid of the dependency to the specific configuration variable. 2023-03-21 22:34:44 +08:00
678d0aa773 Fixed the CSS version of Tempus-Dominus in the base template of the test site. 2023-03-21 21:22:48 +08:00
9248ba7e3b Removed the redundant Flask App context from the default_currency_code Jinja2 global and the default_ie_account_code function. They are always under the Flask app context. 2023-03-21 21:17:10 +08:00
446087b212 Added the ACCOUNTING_DEFAULT_CURRENCY and ACCOUNTING_DEFAULT_IE_ACCOUNT configuration to the test site configuration, for demonstration. 2023-03-21 21:15:14 +08:00
a42e7d13a2 Renamed the configuration DEFAULT_CURRENCY, DEFAULT_IE_ACCOUNT, and RECURRING to "ACCOUNTING_DEFAULT_CURRENCY", "ACCOUNTING_DEFAULT_IE_ACCOUNT", and "ACCOUNTING_RECURRING", respectively. 2023-03-21 21:13:03 +08:00
a82f5091f1 Revised the styles of the buttons in the description editor. 2023-03-21 19:50:57 +08:00
3455827c09 Added the recurring transactions. 2023-03-21 19:45:56 +08:00
5dccf99a55 Renamed "regular" to "recurring" in the description editor. 2023-03-21 17:48:19 +08:00
8818b46e01 Moved the tag initialization from the constructor to the __init_tags method in the DescriptionEditor class. 2023-03-21 17:32:07 +08:00
2f3ad99467 Removed redundant code in the templates of the journal entry form. 2023-03-21 11:54:45 +08:00
592910187b Added the common form-debit-credit.html template to reduce the duplicated code for the currency sub-forms in the transaction form. 2023-03-21 11:47:05 +08:00
cb7a0d377f Added the common form-currency.html template to reduce the duplicated code for the currency sub-forms in the transaction form. 2023-03-21 11:26:28 +08:00
79175285f8 Changed "to transfer" to "as transfer", and updated its Font Awesome icon in the toolbar of the journal entries. 2023-03-21 11:04:45 +08:00
fef474977c Adjust the location of the Material Design floating action buttons for mobile screen. 2023-03-21 10:57:08 +08:00
fa1a55cd3d Adjusted the style for the mobile toolbar for Firefox on Android with large font size. 2023-03-21 10:56:21 +08:00
2253ec7e6d Advanced to version 0.7.0. 2023-03-21 00:54:44 +08:00
32aa532548 Updated the Sphinx documentation. 2023-03-21 00:54:26 +08:00
56138f7de3 Updated the translation. 2023-03-21 00:53:52 +08:00
21ef944259 Fixed the error in the navigation menu when there is no matching endpoint. 2023-03-21 00:53:27 +08:00
760f1c2877 Fixed the query to be case-insensitive in the base account selector in the account form and the account selector in the journal entry form. 2023-03-20 23:54:50 +08:00
e377eac407 Fixed the capitalization of the currencies, base accounts, and accounts. 2023-03-20 23:54:49 +08:00
77787eee9f Fixed the search queries to be case-insensitive. 2023-03-20 23:54:38 +08:00
03265a1232 Fixed the text in the buttons to add new journal entries. 2023-03-20 23:16:57 +08:00
079dc1ab6d Renamed the "eid" field to "id" in the LineItemForm form, since the problem is found. It was the "id" property of the enclosing FormField. If we extract the form from FormField, we can still access the "id" field. 2023-03-20 23:06:57 +08:00
d4fe91ec4a Fixed the capitalization of the shortcut periods in the period chooser. 2023-03-20 22:57:04 +08:00
acc5b4d6ea Fixed the capitalization of the label of the number of items in the description editor. 2023-03-20 22:52:35 +08:00
19a93cb4c3 Fixed the text message in the add_journal_entry view. 2023-03-20 22:44:55 +08:00
116089d1d2 Fixed the text message in the add_currency view. 2023-03-20 22:44:26 +08:00
50dd6078c7 Replaced "Need offset" with "Needs Offset" as the text of the badge. 2023-03-20 22:43:53 +08:00
9a4531b26c Revised the title of the delete confirmation modal from "Delete XXX Confirmation" to "Confirm Delete XXX", as suggested by ChatGPT. 2023-03-20 22:38:35 +08:00
b1af1d7425 Renamed "voucher" to "journal entry". 2023-03-20 22:33:14 +08:00
8f909965a9 Renamed "voucher line item" to "journal entry line item". 2023-03-20 20:52:35 +08:00
e26af6f3fc Renamed "side" to "debit-credit". 2023-03-20 20:35:10 +08:00
02fffc3400 Removed the unused offset_original_line_item_id field from the DebitLineItemForm form. 2023-03-20 18:56:38 +08:00
d7d6929bf2 Fixed the parameter passed to the credit line item subform in the transfer voucher form. 2023-03-20 18:53:16 +08:00
e4cc61552e Simplified the parameter passed to the "form-line-item.html" template for the line item subform of the voucher form, to be less error-prone. 2023-03-20 18:53:11 +08:00
d18dd7d4d2 Renamed "summary" to "description" in the voucher line item. 2023-03-20 18:45:50 +08:00
3251660092 Added the SVG favicon from Font Awesome 6 to the test site. 2023-03-20 08:41:37 +08:00
c1235608d8 Renamed "journal entry" to "voucher line item", and "entry type" to "side". 2023-03-19 22:09:40 +08:00
25c45b16ae Removed the unused imports from the "accounting.voucher.utils.original_entries" module. 2023-03-19 14:15:50 +08:00
78f570b81b Removed an excess trailing blank line in test_summary_editor.py. 2023-03-19 14:05:36 +08:00
5db13393cc Renamed "transaction" to "voucher", "cash expense transaction" to "cash disbursement voucher", and "cash income transaction" to "cash receipt voucher". 2023-03-19 13:56:46 +08:00
1e286fbeba Renamed the #originalEntry, #summary, #account, and #amount private attributes of the JavaScript JournalEntryEditor class to #originalEntryText, #summaryText, #accountText, and #amountInput, to avoid confusion with the public attributes with similar names. 2023-03-19 10:42:37 +08:00
d4b3fe67b9 Removed the originalEntryId parameter from the onOpen method of the JavaScript OriginalEntrySelector class. It can be obtained from the JournalEntryEditor instance, and the parameter is not needed anymore. 2023-03-19 10:25:33 +08:00
5d0757c845 Added the JavaScript JournalEntryEditor instance to the parameters of the constructor of the JavaScript OriginalEntrySelector class, so that it always have access to the JournalEntryEditor instance. Removed the JournalEntryEditor instance from the parameters of the onOpen method of the JournalEntryEditor class. 2023-03-19 10:22:18 +08:00
b69a519904 Updated tempus-dominus from 6.2.10 to 6.4.3 in the base template of the test site. 2023-03-19 10:16:12 +08:00
122b7b059c Changed the default date and min date for the Tempus Dominus month chooser from strings to the JavaScript Date objects. 2023-03-19 10:15:32 +08:00
4977847dd8 Changed the entryType attribute of the JavaScript AccountSelector class from public to private, renamed it from entryType to #entryType. 2023-03-19 07:30:03 +08:00
b9b197ea27 Removed the unused #modal property from the JavaScript OriginalEntrySelector class. 2023-03-19 07:27:25 +08:00
238 changed files with 15552 additions and 8926 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
# Copyright (c) 2022 imacat. # Copyright (c) 2022 imacat.

40
.readthedocs.yaml Normal file
View File

@ -0,0 +1,40 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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.
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
# If using Sphinx, optionally build your docs in additional formats such as PDF
formats: all
# Optionally declare the Python requirements required to build your docs
python:
install:
- method: pip
path: .

View File

@ -1,4 +1,4 @@
# The Mia! Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21 # Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2023 imacat. # Copyright (c) 2022-2023 imacat.
@ -15,16 +15,14 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
recursive-include src/accounting/static *
exclude src/accounting/static/js/dummy.js exclude src/accounting/static/js/dummy.js
include src/accounting/translations/* recursive-include src/accounting/templates *
include src/accounting/translations/*/LC_MESSAGES/* recursive-include src/accounting/translations *
include docs/* recursive-include src/accounting/data *
include docs/source/* recursive-include docs *
include docs/source/_static/* recursive-exclude docs/build *
include docs/source/_templates/* recursive-include tests *
include tests/*
exclude tests/test_temp.py exclude tests/test_temp.py
include tests/test_site/* recursive-exclude tests *.pyc
include tests/test_site/templates/* recursive-exclude tests/instance *
include tests/test_site/translations/*
include tests/test_site/translations/*/LC_MESSAGES/*

View File

@ -1,24 +1,176 @@
===================== ===============
Mia! Accounting Flask Mia! Accounting
===================== ===============
Description Description
=========== ===========
This is the Mia! Accounting Flask project. It is an accounting *Mia! Accounting* is an accounting module for Flask_ applications.
module for the Flask_ applications. It implements `double-entry bookkeeping`_, and generates the following
accounting reports:
* Trial balance
* Income statement
* Balance sheet
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
Install Installation
======= ============
Install the latest source from the Install *Mia! Accounting* with ``pip``:
`Mia! Accounting Flask repository`_.
:: ::
pip install git+https://gitea.imacat.idv.tw/imacat/mia-accounting-flask.git pip install mia-accounting
You may also download the from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Test Site and Live Demonstration
================================
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Prerequisites
=============
You need a running Flask application with database user login.
The primary key of the user data model must be integer.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above
* `Decimal.js`_ 6.4.3 or above
* `Tempus-Dominus`_ 6.4.3 or above
Configuration
=============
You need to pass the Flask *app* and an implementation of
`UserUtilityInterface`_ to the `init_app`_ function.
``UserUtilityInterface`` contains everything *Mia! Accounting* needs.
The following is an example configuration for *Mia! Accounting*.
::
from flask import Response, redirect
from .auth import current_user()
from .modules import User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
import accounting
class UserUtils(accounting.UserUtilityInterface[User]):
def can_view(self) -> bool:
return True
def can_edit(self) -> bool:
return "editor" in current_user().roles
def can_admin(self) -> bool:
return current_user().is_admin
def unauthorized(self) -> Response:
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
return User
@property
def pk_column(self) -> Column:
return User.id
@property
def current_user(self) -> User | None:
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
def get_pk(self, user: User) -> int:
return user.id
accounting.init_app(app, UserUtils())
... (Any other configuration) ...
return app
Database Initialization
=======================
After the configuration, you need to run
`flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
database tables that *Mia! Accounting* uses.
*Mia! Accounting* adds three console commands:
* ``accounting-init-base``
* ``accounting-init-accounts``
* ``accounting-init-currencies``
After database tables are created, run
``accounting-init-base`` first, and then the other two commands.
::
% flask --app myapp accounting-init-base
% flask --app myapp accounting-init-accounts
% flask --app myapp accounting-init-currencies
Navigation Menu
===============
Include the navigation menu in the `Bootstrap navigation bar`_ in your
base template:
::
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<div class="container-fluid">
...
<div id="collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
...
{% include "accounting/include/nav.html" %}
...
</ul>
...
</div>
</div>
</nav>
Check your Flask application and see how it works.
Documentation
=============
Refer to the `documentation on Read the Docs`_.
Copyright Copyright
@ -46,5 +198,22 @@ Authors
| imacat@mail.imacat.idv.tw | imacat@mail.imacat.idv.tw
| 2023/1/27 | 2023/1/27
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com
.. _Mia! Accounting Flask repository: https://gitea.imacat.idv.tw/imacat/mia-accounting-flask .. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _live demonstration: https://accounting.imacat.idv.tw
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _Tempus-Dominus: https://getdatepicker.com
.. _UserUtilityInterface: https://mia-accounting.readthedocs.io/en/latest/accounting.utils.html#accounting.utils.user.UserUtilityInterface
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
accounting.option package
=========================
Submodules
----------
accounting.option.forms module
------------------------------
.. automodule:: accounting.option.forms
:members:
:undoc-members:
:show-inheritance:
accounting.option.views module
------------------------------
.. automodule:: accounting.option.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.option
:members:
:undoc-members:
:show-inheritance:

View File

@ -60,6 +60,22 @@ accounting.report.reports.trial\_balance module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.report.reports.unapplied module
------------------------------------------
.. automodule:: accounting.report.reports.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unapplied\_accounts module
----------------------------------------------------
.. automodule:: accounting.report.reports.unapplied_accounts
:members:
:undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -28,14 +28,6 @@ accounting.report.utils.csv\_export module
:undoc-members: :undoc-members:
:show-inheritance: :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 accounting.report.utils.option\_link module
------------------------------------------- -------------------------------------------
@ -60,6 +52,14 @@ accounting.report.utils.report\_type module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.report.utils.unapplied module
----------------------------------------
.. automodule:: accounting.report.utils.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.urls module accounting.report.utils.urls module
----------------------------------- -----------------------------------

View File

@ -10,13 +10,23 @@ Subpackages
accounting.account accounting.account
accounting.base_account accounting.base_account
accounting.currency accounting.currency
accounting.journal_entry
accounting.option
accounting.report accounting.report
accounting.transaction accounting.unmatched_offset
accounting.utils accounting.utils
Submodules Submodules
---------- ----------
accounting.forms module
-----------------------
.. automodule:: accounting.forms
:members:
:undoc-members:
:show-inheritance:
accounting.locale module accounting.locale module
------------------------ ------------------------

View File

@ -1,45 +0,0 @@
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,46 +0,0 @@
accounting.transaction package
==============================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.transaction.forms
accounting.transaction.utils
Submodules
----------
accounting.transaction.converters module
----------------------------------------
.. automodule:: accounting.transaction.converters
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template\_filters module
-----------------------------------------------
.. automodule:: accounting.transaction.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.views module
-----------------------------------
.. automodule:: accounting.transaction.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,53 +0,0 @@
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

@ -0,0 +1,29 @@
accounting.unmatched\_offset package
====================================
Submodules
----------
accounting.unmatched\_offset.queries module
-------------------------------------------
.. automodule:: accounting.unmatched_offset.queries
:members:
:undoc-members:
:show-inheritance:
accounting.unmatched\_offset.views module
-----------------------------------------
.. automodule:: accounting.unmatched_offset.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.unmatched_offset
:members:
:undoc-members:
:show-inheritance:

View File

@ -12,6 +12,14 @@ accounting.utils.cast module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.current\_account module
----------------------------------------
.. automodule:: accounting.utils.current_account
:members:
:undoc-members:
:show-inheritance:
accounting.utils.flash\_errors module accounting.utils.flash\_errors module
------------------------------------- -------------------------------------
@ -20,6 +28,14 @@ accounting.utils.flash\_errors module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.journal\_entry\_types module
---------------------------------------------
.. automodule:: accounting.utils.journal_entry_types
:members:
:undoc-members:
:show-inheritance:
accounting.utils.next\_uri module accounting.utils.next\_uri module
--------------------------------- ---------------------------------
@ -28,6 +44,30 @@ accounting.utils.next\_uri module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.offset\_alias module
-------------------------------------
.. automodule:: accounting.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.utils.offset\_matcher module
---------------------------------------
.. automodule:: accounting.utils.offset_matcher
:members:
:undoc-members:
:show-inheritance:
accounting.utils.options module
-------------------------------
.. automodule:: accounting.utils.options
:members:
:undoc-members:
:show-inheritance:
accounting.utils.pagination module accounting.utils.pagination module
---------------------------------- ----------------------------------
@ -68,10 +108,10 @@ accounting.utils.strip\_text module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.txn\_types module accounting.utils.unapplied module
---------------------------------- ---------------------------------
.. automodule:: accounting.utils.txn_types .. automodule:: accounting.utils.unapplied
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:

View File

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

61
docs/source/examples.rst Normal file
View File

@ -0,0 +1,61 @@
Examples
========
.. _example-userutils:
An Example Configuration
------------------------
The following is an example configuration for *Mia! Accounting*.
::
from flask import Response, redirect
from .auth import current_user()
from .modules import User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
import accounting
class UserUtils(accounting.UserUtilityInterface[User]):
def can_view(self) -> bool:
return True
def can_edit(self) -> bool:
return "editor" in current_user().roles
def can_admin(self) -> bool:
return current_user().is_admin
def unauthorized(self) -> Response:
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
return User
@property
def pk_column(self) -> Column:
return User.id
@property
def current_user(self) -> User | None:
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
def get_pk(self, user: User) -> int:
return user.id
accounting.init_app(app, UserUtils())
... (Any other configuration) ...
return app

57
docs/source/history.rst Normal file
View File

@ -0,0 +1,57 @@
History
=======
I created my own private accounting application in Perl_/mod_perl_ in
2007, as part of my personal website. The first revision was made
using Perl/Mojolicious_ in 2019, with the aim of making it
mobile-friendly using Bootstrap_, and with modern back-end and
front-end technologies such as jQuery.
The second revision was done in Python_/Django_ in 2020, as I was
looking to change my career from PHP_/Laravel_ to Python, but lacked
experience with large Python projects. I needed something in my
portfolio and decided to work on the somewhat outdated Mojolicious
project.
Despite having no prior experience with Django, I spent two months
working late nights to create the `Mia! Accounting Django`_
application. It took me another 1.5 months to make it an independent
module, which I later released as an open source project on PyPI.
The application worked nicely for my household bookkeeping for two
years. However, new demands arose over time, especially with tracking
payables and receivables. This was critical `during the pandemic`_ as
more payments were made online with credit cards.
The biggest issue I encountered was with
`Django's MTV architectural pattern`_. Django takes over the control
flow. I had to override several parts of the `class-based views`_ for
different but yet simple control flow logic. In the end, it became
very difficult to track whether things went wrong because I overrode
something or because it just wouldn't work with the basic assumption
of the class-based views. By the time I realized it, it was too late
for me to drop Django's MTV and rewrite everything from class-based
views to function-based views.
Therefore, I decided to turn to microframeworks_ like Flask_. After
working with modularized Flask and FastAPI_ applications for two
years, I returned to the project and wrote its third revision using
Flask in 2023.
.. _Perl: https://www.perl.org
.. _mod_perl: https://perl.apache.org
.. _Mojolicious: https://mojolicious.org
.. _Bootstrap: https://getbootstrap.com
.. _jQuery: https://jquery.com
.. _Python: https://www.python.org
.. _Django: https://www.djangoproject.com
.. _PHP: https://www.php.net
.. _Laravel: https://laravel.com
.. _Mia! Accounting Django: https://github.com/imacat/mia-accounting-django
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
.. _FastAPI: https://fastapi.tiangolo.com
.. _Django's MTV architectural pattern: https://docs.djangoproject.com/en/dev/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names
.. _class-based views: https://docs.djangoproject.com/en/4.2/topics/class-based-views/
.. _microframeworks: https://en.wikipedia.org/wiki/Microframework
.. _Flask: https://flask.palletsprojects.com

View File

@ -1,15 +1,20 @@
.. Mia! Accounting Flask documentation master file, created by .. Mia! Accounting documentation master file, created by
sphinx-quickstart on Fri Jan 27 12:20:04 2023. sphinx-quickstart on Fri Jan 27 12:20:04 2023.
You can adapt this file completely to your liking, but it should at least You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive. contain the root `toctree` directive.
Welcome to Mia! Accounting Flask's documentation! Welcome to Mia! Accounting's documentation!
================================================= ===========================================
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
:caption: Contents: :caption: Contents:
intro
accounting
examples
history
Indices and tables Indices and tables

136
docs/source/intro.rst Normal file
View File

@ -0,0 +1,136 @@
Introduction
============
*Mia! Accounting* is an accounting module for Flask_ applications.
It implements `double-entry bookkeeping`_, and generates the following
accounting reports:
* Trial balance
* Income statement
* Balance sheet
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
Installation
------------
Install *Mia! Accounting* with ``pip``:
::
pip install mia-accounting
You may also download the from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Test Site and Live Demonstration
--------------------------------
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Prerequisites
-------------
You need a running Flask application with database user login.
The primary key of the user data model must be integer.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above
* `Decimal.js`_ 6.4.3 or above
* `Tempus-Dominus`_ 6.4.3 or above
Configuration
-------------
You need to pass the Flask *app* and an implementation of
:py:class:`accounting.utils.user.UserUtilityInterface` to the
:py:func:`accounting.init_app` function. ``UserUtilityInterface``
contains everything *Mia! Accounting* needs.
See an example in :ref:`example-userutils`.
Database Initialization
-----------------------
After the configuration, you need to run
`flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
database tables that *Mia! Accounting* uses.
*Mia! Accounting* adds three console commands:
* ``accounting-init-base``
* ``accounting-init-accounts``
* ``accounting-init-currencies``
After database tables are created, run
``accounting-init-base`` first, and then the other two commands.
::
% flask --app myapp accounting-init-base
% flask --app myapp accounting-init-accounts
% flask --app myapp accounting-init-currencies
Navigation Menu
---------------
Include the navigation menu in the `Bootstrap navigation bar`_ in your
base template:
::
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<div class="container-fluid">
...
<div id="collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
...
{% include "accounting/include/nav.html" %}
...
</ul>
...
</div>
</div>
</nav>
Check your Flask application and see how it works.
Documentation
-------------
Refer to the `documentation on Read the Docs`_.
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _live demonstration: https://accounting.imacat.idv.tw
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _Tempus-Dominus: https://getdatepicker.com
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -1,7 +1,7 @@
# The Mia! Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21 # Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022 imacat. # Copyright (c) 2022-2023 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -15,6 +15,51 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
[project]
name = "mia-accounting"
version = "1.1.0"
description = "A Flask accounting module."
readme = "README.rst"
requires-python = ">=3.11"
authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
]
keywords = ["mia", "accounting", "flask"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Framework :: Flask",
"Topic :: Office/Business :: Financial :: Accounting",
]
dependencies = [
"flask",
"Flask-SQLAlchemy",
"Flask-WTF",
"Flask-Babel >= 3",
"Flask-Babel-JS",
]
[project.optional-dependencies]
test = [
"unittest",
"httpx",
"OpenCC",
]
[project.urls]
"Documentation" = "https://mia-accounting.readthedocs.io"
"Repository" = "https://github.com/imacat/mia-accounting"
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
"Demonstration" = "https://accounting.imacat.idv.tw"
[build-system] [build-system]
requires = ["setuptools>=42"] requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.exclude-package-data]
"*" = [
"babel.cfg",
"*.pot",
"*.po",
]

View File

@ -1,56 +0,0 @@
# The Mia! Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-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.
[metadata]
name = mia-accounting-flask
version = 0.6.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.
long_description = file: README.rst
long_description_content_type = text/x-rst
url = https://github.com/imacat/mia-accounting-flask
project_urls =
Bug Tracker = https://github.com/imacat/mia-accounting-flask/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: Apache Software License
Operating System :: OS Independent
Framework :: Flask
Topic :: Office/Business :: Financial :: Accounting
[options]
package_dir =
= src
python_requires = >=3.11
install_requires =
flask
Flask-SQLAlchemy
Flask-WTF
Flask-Babel >= 3
Flask-Babel-JS
tests_require =
unittest
httpx
OpenCC
[options.package_data]
accounting =
static/**
templates/**
translations/*/LC_MESSAGES/*.mo
data/**

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -47,7 +47,6 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
init_user_utils(user_utils) init_user_utils(user_utils)
bp: Blueprint = Blueprint("accounting", __name__, bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix,
template_folder="templates", template_folder="templates",
static_folder="static") static_folder="static")
@ -80,10 +79,16 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import currency from . import currency
currency.init_app(app, bp) currency.init_app(app, bp)
from . import transaction from . import journal_entry
transaction.init_app(app, bp) journal_entry.init_app(app, bp)
from . import report from . import report
report.init_app(app, bp) report.init_app(app, url_prefix)
app.register_blueprint(bp) from . import option
option.init_app(bp)
from . import unmatched_offset
unmatched_offset.init_app(bp)
app.register_blueprint(bp, url_prefix=url_prefix)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -100,22 +100,23 @@ def init_accounts_command(username: str) -> None:
def __is_need_offset(base_code: str) -> bool: def __is_need_offset(base_code: str) -> bool:
"""Checks that whether entries in the account need offset. """Checks that whether journal entry line items in the account need offset.
:param base_code: The code of the base account. :param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise. :return: True if journal entry line items in the account need offset, or
False otherwise.
""" """
# Assets # Assets
if base_code[0] == "1": if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}: if base_code[:3] in {"113", "114", "118", "184", "186"}:
return True return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521", if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
"1581", "1611", "1851", ""}: "1521", "1581", "1611", "1851"}:
return True return True
return False return False
# Liabilities # Liabilities
if base_code[0] == "2": if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}: if base_code in {"2111", "2114", "2284", "2293", "2861"}:
return False return False
return True return True
# Only assets and liabilities need offset # Only assets and liabilities need offset

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -82,7 +82,7 @@ class AccountForm(FlaskForm):
"""The title.""" """The title."""
is_need_offset = BooleanField( is_need_offset = BooleanField(
validators=[NoOffsetNominalAccount()]) validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset.""" """Whether the the journal entry line items of this account need offset."""
def populate_obj(self, obj: Account) -> None: def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object. """Populates the form data into an account object.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -40,14 +40,14 @@ def get_account_query() -> list[Account]:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\ l10n: list[AccountL10n] = AccountL10n.query\
.filter(AccountL10n.title.contains(k)).all() .filter(AccountL10n.title.icontains(k)).all()
l10n_matches: set[str] = {x.account_id for x in l10n} l10n_matches: set[str] = {x.account_id for x in l10n}
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k), = [Account.base_code.contains(k),
Account.title_l10n.contains(k), Account.title_l10n.icontains(k),
code.contains(k), code.contains(k),
Account.id.in_(l10n_matches)] Account.id.in_(l10n_matches)]
if k in gettext("Need offset"): if k in gettext("Needs Offset"):
sub_conditions.append(Account.is_need_offset) sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -53,7 +53,7 @@ def list_accounts() -> str:
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create") @bp.get("create", endpoint="create")
@has_permission(can_edit) @has_permission(can_edit)
def show_add_account_form() -> str: def show_add_account_form() -> str:
"""Shows the form to add an account. """Shows the form to add an account.
@ -70,7 +70,7 @@ def show_add_account_form() -> str:
form=form) form=form)
@bp.post("/store", endpoint="store") @bp.post("store", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_account() -> redirect: def add_account() -> redirect:
"""Adds an account. """Adds an account.
@ -91,7 +91,7 @@ def add_account() -> redirect:
return redirect(inherit_next(__get_detail_uri(account))) return redirect(inherit_next(__get_detail_uri(account)))
@bp.get("/<account:account>", endpoint="detail") @bp.get("<account:account>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_account_detail(account: Account) -> str: def show_account_detail(account: Account) -> str:
"""Shows the account detail. """Shows the account detail.
@ -102,7 +102,7 @@ def show_account_detail(account: Account) -> str:
return render_template("accounting/account/detail.html", obj=account) return render_template("accounting/account/detail.html", obj=account)
@bp.get("/<account:account>/edit", endpoint="edit") @bp.get("<account:account>/edit", endpoint="edit")
@has_permission(can_edit) @has_permission(can_edit)
def show_account_edit_form(account: Account) -> str: def show_account_edit_form(account: Account) -> str:
"""Shows the form to edit an account. """Shows the form to edit an account.
@ -121,7 +121,7 @@ def show_account_edit_form(account: Account) -> str:
account=account, form=form) account=account, form=form)
@bp.post("/<account:account>/update", endpoint="update") @bp.post("<account:account>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_account(account: Account) -> redirect: def update_account(account: Account) -> redirect:
"""Updates an account. """Updates an account.
@ -148,7 +148,7 @@ def update_account(account: Account) -> redirect:
return redirect(inherit_next(__get_detail_uri(account))) return redirect(inherit_next(__get_detail_uri(account)))
@bp.post("/<account:account>/delete", endpoint="delete") @bp.post("<account:account>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_account(account: Account) -> redirect: def delete_account(account: Account) -> redirect:
"""Deletes an account. """Deletes an account.
@ -157,6 +157,9 @@ def delete_account(account: Account) -> redirect:
:return: The redirection to the account list on success, or the account :return: The redirection to the account list on success, or the account
detail on error. detail on error.
""" """
if not account.can_delete:
flash(s(lazy_gettext("The account cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(account)))
account.delete() account.delete()
sort_accounts_in(account.base_code, account.id) sort_accounts_in(account.base_code, account.id)
db.session.commit() db.session.commit()
@ -164,7 +167,7 @@ def delete_account(account: Account) -> redirect:
return redirect(or_next(__get_list_uri())) return redirect(or_next(__get_list_uri()))
@bp.get("/bases/<baseAccount:base>", endpoint="order") @bp.get("bases/<baseAccount:base>", endpoint="order")
@has_permission(can_view) @has_permission(can_view)
def show_account_order(base: BaseAccount) -> str: def show_account_order(base: BaseAccount) -> str:
"""Shows the order of the accounts under a same base account. """Shows the order of the accounts under a same base account.
@ -175,7 +178,7 @@ def show_account_order(base: BaseAccount) -> str:
return render_template("accounting/account/order.html", base=base) return render_template("accounting/account/order.html", base=base)
@bp.post("/bases/<baseAccount:base>", endpoint="sort") @bp.post("bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect: def sort_accounts(base: BaseAccount) -> redirect:
"""Reorders the accounts under a base account. """Reorders the accounts under a base account.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\ l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
.filter(BaseAccountL10n.title.contains(k)).all() .filter(BaseAccountL10n.title.icontains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k), conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.contains(k), BaseAccount.title_l10n.icontains(k),
BaseAccount.code.in_(l10n_matches))) BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\ return BaseAccount.query.filter(*conditions)\
.order_by(BaseAccount.code).all() .order_by(BaseAccount.code).all()

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -22,6 +22,7 @@ from flask import Blueprint, render_template
from accounting.models import BaseAccount from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query
bp: Blueprint = Blueprint("base-account", __name__) bp: Blueprint = Blueprint("base-account", __name__)
"""The view blueprint for the base account management.""" """The view blueprint for the base account management."""
@ -34,14 +35,13 @@ def list_accounts() -> str:
:return: The account list. :return: The account list.
""" """
from .queries import get_base_account_query
accounts: list[BaseAccount] = get_base_account_query() accounts: list[BaseAccount] = get_base_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts) pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/base-account/list.html", return render_template("accounting/base-account/list.html",
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@bp.get("/<baseAccount:account>", endpoint="detail") @bp.get("<baseAccount:account>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_account_detail(account: BaseAccount) -> str: def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail. """Shows the account detail.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\ l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.contains(k)).all() .filter(CurrencyL10n.name.icontains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.contains(k), conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.contains(k), Currency.name_l10n.icontains(k),
Currency.code.in_(l10n_matches))) Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\ return Currency.query.filter(*conditions)\
.order_by(Currency.code).all() .order_by(Currency.code).all()

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -34,6 +34,7 @@ from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm from .forms import CurrencyForm
from .queries import get_currency_query
bp: Blueprint = Blueprint("currency", __name__) bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management.""" """The view blueprint for the currency management."""
@ -48,14 +49,13 @@ def list_currencies() -> str:
:return: The currency list. :return: The currency list.
""" """
from .queries import get_currency_query
currencies: list[Currency] = get_currency_query() currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies) pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html", return render_template("accounting/currency/list.html",
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create") @bp.get("create", endpoint="create")
@has_permission(can_edit) @has_permission(can_edit)
def show_add_currency_form() -> str: def show_add_currency_form() -> str:
"""Shows the form to add a currency. """Shows the form to add a currency.
@ -72,7 +72,7 @@ def show_add_currency_form() -> str:
form=form) form=form)
@bp.post("/store", endpoint="store") @bp.post("store", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_currency() -> redirect: def add_currency() -> redirect:
"""Adds a currency. """Adds a currency.
@ -89,11 +89,11 @@ def add_currency() -> redirect:
form.populate_obj(currency) form.populate_obj(currency)
db.session.add(currency) db.session.add(currency)
db.session.commit() db.session.commit()
flash(s(lazy_gettext("The currency is added successfully")), "success") flash(s(lazy_gettext("The currency is added successfully.")), "success")
return redirect(inherit_next(__get_detail_uri(currency))) return redirect(inherit_next(__get_detail_uri(currency)))
@bp.get("/<currency:currency>", endpoint="detail") @bp.get("<currency:currency>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_currency_detail(currency: Currency) -> str: def show_currency_detail(currency: Currency) -> str:
"""Shows the currency detail. """Shows the currency detail.
@ -104,7 +104,7 @@ def show_currency_detail(currency: Currency) -> str:
return render_template("accounting/currency/detail.html", obj=currency) return render_template("accounting/currency/detail.html", obj=currency)
@bp.get("/<currency:currency>/edit", endpoint="edit") @bp.get("<currency:currency>/edit", endpoint="edit")
@has_permission(can_edit) @has_permission(can_edit)
def show_currency_edit_form(currency: Currency) -> str: def show_currency_edit_form(currency: Currency) -> str:
"""Shows the form to edit a currency. """Shows the form to edit a currency.
@ -123,7 +123,7 @@ def show_currency_edit_form(currency: Currency) -> str:
currency=currency, form=form) currency=currency, form=form)
@bp.post("/<currency:currency>/update", endpoint="update") @bp.post("<currency:currency>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_currency(currency: Currency) -> redirect: def update_currency(currency: Currency) -> redirect:
"""Updates a currency. """Updates a currency.
@ -151,7 +151,7 @@ def update_currency(currency: Currency) -> redirect:
return redirect(inherit_next(__get_detail_uri(currency))) return redirect(inherit_next(__get_detail_uri(currency)))
@bp.post("/<currency:currency>/delete", endpoint="delete") @bp.post("<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect: def delete_currency(currency: Currency) -> redirect:
"""Deletes a currency. """Deletes a currency.
@ -160,13 +160,16 @@ def delete_currency(currency: Currency) -> redirect:
:return: The redirection to the currency list on success, or the currency :return: The redirection to the currency list on success, or the currency
detail on error. detail on error.
""" """
if not currency.can_delete:
flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(currency)))
currency.delete() currency.delete()
db.session.commit() db.session.commit()
flash(s(lazy_gettext("The currency is deleted successfully.")), "success") flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
return redirect(or_next(url_for("accounting.currency.list"))) return redirect(or_next(url_for("accounting.currency.list")))
@api_bp.get("/exists-code", endpoint="exists") @api_bp.get("exists-code", endpoint="exists")
@has_permission(can_edit) @has_permission(can_edit)
def exists_code() -> dict[str, bool]: def exists_code() -> dict[str, bool]:
"""Validates whether a currency code exists. """Validates whether a currency code exists.

96
src/accounting/forms.py Normal file
View File

@ -0,0 +1,96 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms.
"""
import re
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
"""The validator to check if the account code is empty."""
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class AccountExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class IsDebitAccount:
"""The validator to check if the account is for debit line items."""
def __init__(self, message: str | LazyString):
"""Constructs the validator.
:param message: The error message.
"""
self.__message: str | LazyString = message
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)
class IsCreditAccount:
"""The validator to check if the account is for credit line items."""
def __init__(self, message: str | LazyString):
"""Constructs the validator.
:param message: The error message.
"""
self.__message: str | LazyString = message
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -14,9 +14,9 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The forms for the transaction management. """The forms for the journal entry management.
""" """
from .reorder import sort_transactions_in, TransactionReorderForm from .reorder import sort_journal_entries_in, JournalEntryReorderForm
from .transaction import TransactionForm, IncomeTransactionForm, \ from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
ExpenseTransactionForm, TransferTransactionForm CashDisbursementJournalEntryForm, TransferJournalEntryForm

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The currency sub-forms for the transaction management. """The currency sub-forms for the journal entry management.
""" """
from decimal import Decimal from decimal import Decimal
@ -27,49 +27,41 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from accounting import db from accounting import db
from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Currency, JournalEntry from accounting.models import JournalEntryLineItem
from accounting.transaction.utils.offset_alias import offset_alias
from accounting.utils.cast import be from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
CURRENCY_REQUIRED: DataRequired = DataRequired( CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency.")) lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty.""" """The validator to check if the currency code is empty."""
class CurrencyExists: class SameCurrencyAsOriginalLineItems:
"""The validator to check if the account exists.""" """The validator to check if the currency is the same as the
original line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class SameCurrencyAsOriginalEntries:
"""The validator to check if the currency is the same as the original
entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm) assert isinstance(form, CurrencyForm)
if field.data is None: if field.data is None:
return return
original_entry_id: set[int] = {x.original_entry_id.data original_line_item_id: set[int] \
for x in form.entries = {x.original_line_item_id.data
if x.original_entry_id.data is not None} for x in form.line_items
if len(original_entry_id) == 0: if x.original_line_item_id.data is not None}
if len(original_line_item_id) == 0:
return return
original_entry_currency_codes: set[str] = set(db.session.scalars( original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code) sa.select(JournalEntryLineItem.currency_code)
.filter(JournalEntry.id.in_(original_entry_id))).all()) .filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_entry_currency_codes: for currency_code in original_line_item_currency_codes:
if field.data != currency_code: if field.data != currency_code:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The currency must be the same as the original entry.")) "The currency must be the same as the"
" original line item."))
class KeepCurrencyWhenHavingOffset: class KeepCurrencyWhenHavingOffset:
@ -81,31 +73,35 @@ class KeepCurrencyWhenHavingOffset:
if field.data is None: if field.data is None:
return return
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
original_entries: list[JournalEntry] = JournalEntry.query\ original_line_items: list[JournalEntryLineItem]\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id), = JournalEntryLineItem.query\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\ isouter=True)\
.filter(JournalEntry.id.in_({x.eid.data for x in form.entries .filter(JournalEntryLineItem.id
if x.eid.data is not None}))\ .in_({x.id.data for x in form.line_items
.group_by(JournalEntry.id, JournalEntry.currency_code)\ if x.id.data is not None}))\
.group_by(JournalEntryLineItem.id,
JournalEntryLineItem.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all() .having(sa.func.count(offset.c.id) > 0).all()
for original_entry in original_entries: for original_line_item in original_line_items:
if original_entry.currency_code != field.data: if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The currency must not be changed when there is offset.")) "The currency must not be changed when there is offset."))
class NeedSomeJournalEntries: class NeedSomeLineItems:
"""The validator to check if there is any journal entry sub-form.""" """The validator to check if there is any line item sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None: def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0: if len(field) == 0:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"Please add some journal entries.")) "Please add some line items."))
class IsBalanced: class IsBalanced:
"""The validator to check that the total amount of the debit and credit """The validator to check that the total amount of the debit and credit
entries are equal.""" line items are equal."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None: def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, TransferCurrencyForm) assert isinstance(form, TransferCurrencyForm)
@ -117,29 +113,29 @@ class IsBalanced:
class CurrencyForm(FlaskForm): class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction.""" """The form to create or edit a currency in a journal entry."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the journal entry."""
code = StringField() code = StringField()
"""The currency code.""" """The currency code."""
whole_form = BooleanField() whole_form = BooleanField()
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def entries(self) -> list[JournalEntryForm]: def line_items(self) -> list[LineItemForm]:
"""Returns the journal entry sub-forms. """Returns the line item sub-forms.
:return: The journal entry sub-forms. :return: The line item sub-forms.
""" """
entry_forms: list[JournalEntryForm] = [] line_item_forms: list[LineItemForm] = []
if isinstance(self, IncomeCurrencyForm): if isinstance(self, CashReceiptCurrencyForm):
entry_forms.extend([x.form for x in self.credit]) line_item_forms.extend([x.form for x in self.credit])
elif isinstance(self, ExpenseCurrencyForm): elif isinstance(self, CashDisbursementCurrencyForm):
entry_forms.extend([x.form for x in self.debit]) line_item_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm): elif isinstance(self, TransferCurrencyForm):
entry_forms.extend([x.form for x in self.debit]) line_item_forms.extend([x.form for x in self.debit])
entry_forms.extend([x.form for x in self.credit]) line_item_forms.extend([x.form for x in self.credit])
return entry_forms return line_item_forms
@property @property
def is_code_locked(self) -> bool: def is_code_locked(self) -> bool:
@ -148,48 +144,50 @@ class CurrencyForm(FlaskForm):
:return: True if the currency code should not be changed, or False :return: True if the currency code should not be changed, or False
otherwise otherwise
""" """
entry_forms: list[JournalEntryForm] = self.entries line_item_forms: list[LineItemForm] = self.line_items
original_entry_id: set[int] \ original_line_item_id: set[int] \
= {x.original_entry_id.data for x in entry_forms = {x.original_line_item_id.data for x in line_item_forms
if x.original_entry_id.data is not None} if x.original_line_item_id.data is not None}
if len(original_entry_id) > 0: if len(original_line_item_id) > 0:
return True return True
entry_id: set[int] = {x.eid.data for x in entry_forms line_item_id: set[int] = {x.id.data for x in line_item_forms
if x.eid.data is not None} if x.id.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\ select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
.filter(JournalEntry.original_entry_id.in_(entry_id)) .filter(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select) > 0 return db.session.scalar(select) > 0
class IncomeCurrencyForm(CurrencyForm): class CashReceiptCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction.""" """The form to create or edit a currency in a
cash receipt journal entry."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the journal entry."""
code = StringField( code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
CurrencyExists(), CurrencyExists(),
SameCurrencyAsOriginalEntries(), SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()]) KeepCurrencyWhenHavingOffset()])
"""The currency code.""" """The currency code."""
credit = FieldList(FormField(CreditEntryForm), credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The credit entries.""" """The credit line items."""
whole_form = BooleanField() whole_form = BooleanField()
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def credit_total(self) -> Decimal: def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries. """Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries. :return: The total amount of the credit line items.
""" """
return sum([x.amount.data for x in self.credit return sum([x.amount.data for x in self.credit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def credit_errors(self) -> list[str | LazyString]: def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their """Returns the credit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:
@ -198,35 +196,36 @@ class IncomeCurrencyForm(CurrencyForm):
if isinstance(x, str) or isinstance(x, LazyString)] if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseCurrencyForm(CurrencyForm): class CashDisbursementCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction.""" """The form to create or edit a currency in a
cash disbursement journal entry."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the journal entry."""
code = StringField( code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
CurrencyExists(), CurrencyExists(),
SameCurrencyAsOriginalEntries(), SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()]) KeepCurrencyWhenHavingOffset()])
"""The currency code.""" """The currency code."""
debit = FieldList(FormField(DebitEntryForm), debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The debit entries.""" """The debit line items."""
whole_form = BooleanField() whole_form = BooleanField()
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def debit_total(self) -> Decimal: def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries. """Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries. :return: The total amount of the debit line items.
""" """
return sum([x.amount.data for x in self.debit return sum([x.amount.data for x in self.debit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def debit_errors(self) -> list[str | LazyString]: def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their """Returns the debit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:
@ -236,46 +235,46 @@ class ExpenseCurrencyForm(CurrencyForm):
class TransferCurrencyForm(CurrencyForm): class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction.""" """The form to create or edit a currency in a transfer journal entry."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the journal entry."""
code = StringField( code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
CurrencyExists(), CurrencyExists(),
SameCurrencyAsOriginalEntries(), SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()]) KeepCurrencyWhenHavingOffset()])
"""The currency code.""" """The currency code."""
debit = FieldList(FormField(DebitEntryForm), debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The debit entries.""" """The debit line items."""
credit = FieldList(FormField(CreditEntryForm), credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The credit entries.""" """The credit line items."""
whole_form = BooleanField(validators=[IsBalanced()]) whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def debit_total(self) -> Decimal: def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries. """Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries. :return: The total amount of the debit line items.
""" """
return sum([x.amount.data for x in self.debit return sum([x.amount.data for x in self.debit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def credit_total(self) -> Decimal: def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries. """Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries. :return: The total amount of the credit line items.
""" """
return sum([x.amount.data for x in self.credit return sum([x.amount.data for x in self.credit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def debit_errors(self) -> list[str | LazyString]: def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their """Returns the debit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:
@ -285,7 +284,7 @@ class TransferCurrencyForm(CurrencyForm):
@property @property
def credit_errors(self) -> list[str | LazyString]: def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their """Returns the credit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:

View File

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

View File

@ -0,0 +1,496 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The line item sub-forms for the journal entry management.
"""
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import Optional
from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class OriginalLineItemExists:
"""The validator to check if the original line item exists."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
if db.session.get(JournalEntryLineItem, field.data) is None:
raise ValidationError(lazy_gettext(
"The original line item does not exist."))
class OriginalLineItemOppositeDebitCredit:
"""The validator to check if the original line item is on the opposite
debit or credit."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if isinstance(form, CreditLineItemForm) \
and original_line_item.is_debit:
return
if isinstance(form, DebitLineItemForm) \
and not original_line_item.is_debit:
return
raise ValidationError(lazy_gettext(
"The original line item is on the same debit or credit."))
class OriginalLineItemNeedOffset:
"""The validator to check if the original line item needs offset."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if not original_line_item.account.is_need_offset:
raise ValidationError(lazy_gettext(
"The original line item does not need offset."))
class OriginalLineItemNotOffset:
"""The validator to check if the original line item is not itself an
offset item."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if original_line_item.original_line_item_id is not None:
raise ValidationError(lazy_gettext(
"The original line item cannot be an offset item."))
class SameAccountAsOriginalLineItem:
"""The validator to check if the account is the same as the
original line item."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem,
form.original_line_item_id.data)
if original_line_item is None:
return
if field.data != original_line_item.account_code:
raise ValidationError(lazy_gettext(
"The account must be the same as the original line item."))
class KeepAccountWhenHavingOffset:
"""The validator to check if the account is the same when having offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None:
return
line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, form.id.data)
if line_item is None or len(line_item.offsets) == 0:
return
if field.data != line_item.account_code:
raise ValidationError(lazy_gettext(
"The account must not be changed when there is offset."))
class NotStartPayableFromDebit:
"""The validator to check that a payable line item does not start from
debit."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, DebitLineItemForm)
if field.data is None \
or field.data[0] != "2" \
or form.original_line_item_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"A payable line item cannot start from debit."))
class NotStartReceivableFromCredit:
"""The validator to check that a receivable line item does not start
from credit."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CreditLineItemForm)
if field.data is None \
or field.data[0] != "1" \
or form.original_line_item_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"A receivable line item cannot start from credit."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
if field.data is None:
return
if field.data <= 0:
raise ValidationError(lazy_gettext(
"Please fill in a positive amount."))
class NotExceedingOriginalLineItemNetBalance:
"""The validator to check if the amount exceeds the net balance of the
original line item."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem,
form.original_line_item_id.data)
if original_line_item is None:
return
is_debit: bool = isinstance(form, DebitLineItemForm)
existing_line_item_id: set[int] = set()
if form.journal_entry_form.obj is not None:
existing_line_item_id \
= {x.id for x in form.journal_entry_form.obj.line_items}
offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntryLineItem.is_debit == is_debit),
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(be(JournalEntryLineItem.original_line_item_id
== original_line_item.id),
JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None:
offset_total_but_form = Decimal("0")
offset_total_on_form: Decimal = sum(
[x.amount.data for x in form.journal_entry_form.line_items
if x.original_line_item_id.data == original_line_item.id
and x.amount != field and x.amount.data is not None])
net_balance: Decimal = original_line_item.amount \
- offset_total_but_form - offset_total_on_form
if field.data > net_balance:
raise ValidationError(lazy_gettext(
"The amount must not exceed the net balance %(balance)s of the"
" original line item.", balance=format_amount(net_balance)))
class NotLessThanOffsetTotal:
"""The validator to check if the amount is less than the offset total."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None:
return
is_debit: bool = isinstance(form, DebitLineItemForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\
.filter(be(JournalEntryLineItem.original_line_item_id
== form.id.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext(
"The amount must not be less than the offset total %(total)s.",
total=format_amount(offset_total)))
class LineItemForm(FlaskForm):
"""The base form to create or edit a line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField()
"""The Id of the original line item."""
account_code = StringField()
"""The account code."""
description = StringField()
"""The description."""
amount = DecimalField()
"""The amount."""
def __init__(self, *args, **kwargs):
"""Constructs a base line item form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
from .journal_entry import JournalEntryForm
self.journal_entry_form: JournalEntryForm | None = None
"""The source journal entry form."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
:return: The text representation of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return str(account)
@property
def __original_line_item(self) -> JournalEntryLineItem | None:
"""Returns the original line item.
:return: The original line item.
"""
if not hasattr(self, "____original_line_item"):
def get_line_item() -> JournalEntryLineItem | None:
if self.original_line_item_id.data is None:
return None
return db.session.get(JournalEntryLineItem,
self.original_line_item_id.data)
setattr(self, "____original_line_item", get_line_item())
return getattr(self, "____original_line_item")
@property
def original_line_item_date(self) -> date | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original line item.
"""
return None if self.__original_line_item is None \
else self.__original_line_item.journal_entry.date
@property
def original_line_item_text(self) -> str | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original line item.
"""
return None if self.__original_line_item is None \
else str(self.__original_line_item)
@property
def is_need_offset(self) -> bool:
"""Returns whether the line item needs offset.
:return: True if the line item needs offset, or False otherwise.
"""
if self.account_code.data is None:
return False
if self.account_code.data[0] == "1":
if isinstance(self, CreditLineItemForm):
return False
elif self.account_code.data[0] == "2":
if isinstance(self, DebitLineItemForm):
return False
else:
return False
account: Account | None = Account.find_by_code(self.account_code.data)
return account is not None and account.is_need_offset
@property
def offsets(self) -> list[JournalEntryLineItem]:
"""Returns the offsets.
:return: The offsets.
"""
if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None:
return []
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account)).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
@property
def offset_total(self) -> Decimal | None:
"""Returns the total amount of the offsets.
:return: The total amount of the offsets.
"""
if not hasattr(self, "__offset_total"):
def get_offset_total():
if not self.is_need_offset or self.id.data is None:
return None
is_debit: bool = isinstance(self, DebitLineItemForm)
return sum([x.amount if x.is_debit != is_debit else -x.amount
for x in self.offsets])
setattr(self, "__offset_total", get_offset_total())
return getattr(self, "__offset_total")
@property
def net_balance(self) -> Decimal | None:
"""Returns the net balance.
:return: The net balance.
"""
if not self.is_need_offset or self.id.data is None \
or self.amount.data is None:
return None
return self.amount.data - self.offset_total
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class DebitLineItemForm(LineItemForm):
"""The form to create or edit a debit line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(lazy_gettext(
"This account is not for debit line items.")),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
"""The account code."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntryLineItem)
obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.description = self.description.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class CreditLineItemForm(LineItemForm):
"""The form to create or edit a credit line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(lazy_gettext(
"This account is not for credit line items.")),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])
"""The account code."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntryLineItem)
obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.description = self.description.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk

View File

@ -0,0 +1,95 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The reorder forms for the journal entry management.
"""
from datetime import date
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import JournalEntry
def sort_journal_entries_in(journal_entry_date: date,
exclude: int | None = None) -> None:
"""Sorts the journal entries under a date after changing the date or
deleting a journal entry.
:param journal_entry_date: The date of the journal entry.
:param exclude: The journal entry ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.date == journal_entry_date]
if exclude is not None:
conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(*conditions)\
.order_by(JournalEntry.no).all()
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
class JournalEntryReorderForm:
"""The form to reorder the journal entries."""
def __init__(self, journal_entry_date: date):
"""Constructs the form to reorder the journal entries in a day.
:param journal_entry_date: The date.
"""
self.date: date = journal_entry_date
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.date == self.date).all()
# Collects the specified order.
orders: dict[JournalEntry, int] = {}
for journal_entry in journal_entries:
if f"{journal_entry.id}-no" in request.form:
try:
orders[journal_entry] \
= int(request.form[f"{journal_entry.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[JournalEntry] \
= [x for x in journal_entries if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for journal_entry in missing:
orders[journal_entry] = next_no
# Sort by the specified order first, and their original order.
journal_entries.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
self.is_modified = True

View File

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

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -14,6 +14,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The utilities for the transaction management. """The utilities for the journal entry management.
""" """

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The account option for the transaction management. """The account option for the journal entry management.
""" """
from accounting.models import Account from accounting.models import Account

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -14,32 +14,36 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The summary editor. """The description editor.
""" """
import re
import typing as t import typing as t
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import Account, JournalEntry from accounting.models import Account, JournalEntryLineItem
from accounting.utils.options import options, Recurring
class SummaryAccount: class DescriptionAccount:
"""An account for a summary tag.""" """An account for a description tag."""
def __init__(self, account: Account, freq: int): def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag. """Constructs an account for a description tag.
:param account: The account. :param account: The account.
:param freq: The frequency of the tag with the account. :param freq: The frequency of the tag with the account.
""" """
self.account: Account = account self.__account: Account = account
"""The account.""" """The account."""
self.id: int = account.id self.id: int = account.id
"""The account ID.""" """The account ID."""
self.code: str = account.code self.code: str = account.code
"""The account code.""" """The account code."""
self.is_need_offset: bool = account.is_need_offset
"""Whether the journal entry line items of this account need offset."""
self.freq: int = freq self.freq: int = freq
"""The frequency of the tag with the account.""" """The frequency of the tag with the account."""
@ -48,7 +52,7 @@ class SummaryAccount:
:return: The string representation of the account. :return: The string representation of the account.
""" """
return str(self.account) return str(self.__account)
def add_freq(self, freq: int) -> None: def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account. """Adds the frequency of an account.
@ -59,17 +63,17 @@ class SummaryAccount:
self.freq = self.freq + freq self.freq = self.freq + freq
class SummaryTag: class DescriptionTag:
"""A summary tag.""" """A description tag."""
def __init__(self, name: str): def __init__(self, name: str):
"""Constructs a summary tag. """Constructs a description tag.
:param name: The tag name. :param name: The tag name.
""" """
self.name: str = name self.name: str = name
"""The tag name.""" """The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {} self.__account_dict: dict[int, DescriptionAccount] = {}
"""The accounts that come with the tag, in the order of their """The accounts that come with the tag, in the order of their
frequency.""" frequency."""
self.freq: int = 0 self.freq: int = 0
@ -89,11 +93,11 @@ class SummaryTag:
:param freq: The frequency of the tag name with the account. :param freq: The frequency of the tag name with the account.
:return: None. :return: None.
""" """
self.__account_dict[account.id] = SummaryAccount(account, freq) self.__account_dict[account.id] = DescriptionAccount(account, freq)
self.freq = self.freq + freq self.freq = self.freq + freq
@property @property
def accounts(self) -> list[SummaryAccount]: def accounts(self) -> list[DescriptionAccount]:
"""Returns the accounts by the order of their frequencies. """Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies. :return: The accounts by the order of their frequencies.
@ -109,17 +113,17 @@ class SummaryTag:
return [x.code for x in self.accounts] return [x.code for x in self.accounts]
class SummaryType: class DescriptionType:
"""A summary type""" """A description type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]): def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type. """Constructs a description type.
:param type_id: The type ID, either "general", "travel", or "bus". :param type_id: The type ID, either "general", "travel", or "bus".
""" """
self.id: t.Literal["general", "travel", "bus"] = type_id self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID.""" """The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {} self.__tag_dict: dict[str, DescriptionTag] = {}
"""A dictionary from the tag name to their corresponding tag.""" """A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None: def add_tag(self, name: str, account: Account, freq: int) -> None:
@ -131,11 +135,11 @@ class SummaryType:
:return: None. :return: None.
""" """
if name not in self.__tag_dict: if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name) self.__tag_dict[name] = DescriptionTag(name)
self.__tag_dict[name].add_account(account, freq) self.__tag_dict[name].add_account(account, freq)
@property @property
def tags(self) -> list[SummaryTag]: def tags(self) -> list[DescriptionTag]:
"""Returns the tags by the order of their frequencies. """Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies. :return: The tags by the order of their frequencies.
@ -143,26 +147,51 @@ class SummaryType:
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType: class DescriptionRecurring:
"""A summary type""" """A recurring transaction."""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]): def __init__(self, name: str, account: Account, description_template: str):
"""Constructs a summary entry type. """Constructs a recurring transaction.
:param entry_type_id: The entry type ID, either "debit" or "credit". :param name: The name.
:param description_template: The description template.
:param account: The account.
""" """
self.type: t.Literal["debit", "credit"] = entry_type_id self.name: str = name
"""The entry type.""" self.account: DescriptionAccount = DescriptionAccount(account, 0)
self.general: SummaryType = SummaryType("general") self.description_template: str = description_template
@property
def account_codes(self) -> list[str]:
"""Returns the account codes by the order of their frequencies.
:return: The account codes by the order of their frequencies.
"""
return [self.account.code]
class DescriptionDebitCredit:
"""The description on debit or credit."""
def __init__(self, debit_credit: t.Literal["debit", "credit"]):
"""Constructs the description on debit or credit.
:param debit_credit: Either "debit" or "credit".
"""
self.debit_credit: t.Literal["debit", "credit"] = debit_credit
"""Either debit or credit."""
self.general: DescriptionType = DescriptionType("general")
"""The general tags.""" """The general tags."""
self.travel: SummaryType = SummaryType("travel") self.travel: DescriptionType = DescriptionType("travel")
"""The travel tags.""" """The travel tags."""
self.bus: SummaryType = SummaryType("bus") self.bus: DescriptionType = DescriptionType("bus")
"""The bus tags.""" """The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"], self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \ DescriptionType] \
= {x.id: x for x in {self.general, self.travel, self.bus}} = {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags.""" """A dictionary from the type ID to the corresponding tags."""
self.recurring: list[DescriptionRecurring] = []
"""The recurring transactions."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"], def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None: name: str, account: Account, freq: int) -> None:
@ -177,13 +206,13 @@ class SummaryEntryType:
self.__type_dict[tag_type].add_tag(name, account, freq) self.__type_dict[tag_type].add_tag(name, account, freq)
@property @property
def accounts(self) -> list[SummaryAccount]: def accounts(self) -> list[DescriptionAccount]:
"""Returns the suggested accounts of all tags in the summary editor in """Returns the suggested accounts of all tags in the description editor
the entry type, in their frequency order. in debit or credit, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order. :return: The suggested accounts of all tags, in their frequency order.
""" """
accounts: dict[int, SummaryAccount] = {} accounts: dict[int, DescriptionAccount] = {}
freq: dict[int, int] = {} freq: dict[int, int] = {}
for tag_type in self.__type_dict.values(): for tag_type in self.__type_dict.values():
for tag in tag_type.tags: for tag in tag_type.tags:
@ -193,44 +222,105 @@ class SummaryEntryType:
freq[account.id] = 0 freq[account.id] = 0
freq[account.id] \ freq[account.id] \
= freq[account.id] + account.freq = freq[account.id] + account.freq
for recurring in self.recurring:
accounts[recurring.account.id] = recurring.account
if recurring.account.id not in freq:
freq[recurring.account.id] = 0
return [accounts[y] for y in sorted(freq.keys(), return [accounts[y] for y in sorted(freq.keys(),
key=lambda x: -freq[x])] key=lambda x: -freq[x])]
class SummaryEditor: class DescriptionEditor:
"""The summary editor.""" """The description editor."""
def __init__(self): def __init__(self):
"""Constructs the summary editor.""" """Constructs the description editor."""
self.debit: SummaryEntryType = SummaryEntryType("debit") self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
"""The debit tags.""" """The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit") self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
"""The credit tags.""" """The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"), self.__init_tags()
else_="credit").label("entry_type") self.__init_recurring()
def __init_tags(self):
"""Initializes the tags.
:return: None.
"""
debit_credit: sa.Label = sa.case(
(JournalEntryLineItem.is_debit, "debit"),
else_="credit").label("debit_credit")
tag_type: sa.Label = sa.case( tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"), (JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"), (sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"), JournalEntryLineItem.description.like("_%—_%↔_%")),
"travel"),
else_="general").label("tag_type") else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag") tag: sa.Label = get_prefix(JournalEntryLineItem.description, "")\
select: sa.Select = sa.Select(entry_type, tag_type, tag, .label("tag")
JournalEntry.account_id, select: sa.Select = sa.Select(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id,
sa.func.count().label("freq"))\ sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None), .filter(JournalEntryLineItem.description.is_not(None),
JournalEntry.summary.like("_%—_%"), JournalEntryLineItem.description.like("_%—_%"),
JournalEntry.original_entry_id.is_(None))\ JournalEntryLineItem.original_line_item_id.is_(None))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id) .group_by(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id)
result: list[sa.Row] = db.session.execute(select).all() result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query = {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()} .filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \ debit_credit_dict: dict[t.Literal["debit", "credit"],
= {x.type: x for x in {self.debit, self.credit}} DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result: for row in result:
entry_type_dict[row.entry_type].add_tag( debit_credit_dict[row.debit_credit].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq) row.tag_type, row.tag, accounts[row.account_id], row.freq)
def __init_recurring(self) -> None:
"""Initializes the recurring transactions.
:return: None.
"""
recurring: Recurring = options.recurring
accounts: dict[str, Account] \
= self.__get_accounts(recurring.codes)
self.debit.recurring \
= [DescriptionRecurring(x.name, accounts[x.account_code],
x.description_template)
for x in recurring.expenses]
self.credit.recurring \
= [DescriptionRecurring(x.name, accounts[x.account_code],
x.description_template)
for x in recurring.incomes]
@staticmethod
def __get_accounts(codes: set[str]) -> dict[str, Account]:
"""Finds and returns the accounts by codes.
:param codes: The account codes.
:return: The account.
"""
if len(codes) == 0:
return {}
def get_condition(code0: str) -> sa.BinaryExpression:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None,\
f"Malformed account code \"{code0}\" for regular transactions."
return sa.and_(Account.base_code == m.group(1),
Account.no == int(m.group(2)))
conditions: list[sa.BinaryExpression] \
= [get_condition(x) for x in codes]
accounts: dict[str, Account] \
= {x.code: x for x in
Account.query.filter(sa.or_(*conditions)).all()}
for code in codes:
assert code in accounts,\
f"Unknown account \"{code}\" for regular transactions."
return accounts
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \ def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function: -> sa.Function:

View File

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

View File

@ -0,0 +1,85 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The selectable original line items.
"""
from decimal import Decimal
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
def get_selectable_original_line_items(
line_item_id_on_form: set[int], is_payable: bool,
is_receivable: bool) -> list[JournalEntryLineItem]:
"""Queries and returns the selectable original line items, with their net
balances. The offset amounts of the form is excluded.
:param line_item_id_on_form: The ID of the line items on the form.
:param is_payable: True to check the payable original line items, or False
otherwise.
:param is_receivable: True to check the receivable original line items, or
False otherwise.
:return: The selectable original line items, with their net balances.
"""
assert is_payable or is_receivable
offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
(offset.c.id.in_(line_item_id_on_form), 0),
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)))
if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit))
conditions.append(sa.or_(*sub_conditions))
select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance)\
.join(Account)\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\
.filter(*conditions)\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
.join(JournalEntry)\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry)).all()
line_items.reverse()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
return line_items

View File

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

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -21,12 +21,11 @@ from __future__ import annotations
import re import re
import typing as t import typing as t
from datetime import date
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import current_app from babel import Locale
from flask_babel import get_locale from flask_babel import get_locale, get_babel
from sqlalchemy import text from sqlalchemy import text
from accounting import db from accounting import db
@ -53,7 +52,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account. :return: The string representation of the base account.
""" """
return f"{self.code} {self.title}" return f"{self.code} {self.title.title()}"
@property @property
def title(self) -> str: def title(self) -> str:
@ -61,11 +60,11 @@ class BaseAccount(db.Model):
:return: The title in the current locale. :return: The title in the current locale.
""" """
current_locale = str(get_locale()) current_locale: Locale = get_locale()
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: if current_locale == get_babel().instance.default_locale:
return self.title_l10n return self.title_l10n
for l10n in self.l10n: for l10n in self.l10n:
if l10n.locale == current_locale: if l10n.locale == str(current_locale):
return l10n.title return l10n.title
return self.title_l10n return self.title_l10n
@ -115,7 +114,7 @@ class Account(db.Model):
title_l10n = db.Column("title", db.String, nullable=False) title_l10n = db.Column("title", db.String, nullable=False)
"""The title.""" """The title."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False) is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offset.""" """Whether the journal entry line items of this account need offset."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now()) server_default=db.func.now())
"""The time of creation.""" """The time of creation."""
@ -139,8 +138,9 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account", l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False) lazy=False)
"""The localized titles.""" """The localized titles."""
entries = db.relationship("JournalEntry", back_populates="account") line_items = db.relationship("JournalEntryLineItem",
"""The journal entries.""" back_populates="account")
"""The journal entry line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
"""The code of the cash account,""" """The code of the cash account,"""
@ -154,7 +154,7 @@ class Account(db.Model):
:return: The string representation of this account. :return: The string representation of this account.
""" """
return f"{self.base_code}-{self.no:03d} {self.title}" return f"{self.base_code}-{self.no:03d} {self.title.title()}"
@property @property
def code(self) -> str: def code(self) -> str:
@ -170,11 +170,11 @@ class Account(db.Model):
:return: The title in the current locale. :return: The title in the current locale.
""" """
current_locale = str(get_locale()) current_locale: Locale = get_locale()
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: if current_locale == get_babel().instance.default_locale:
return self.title_l10n return self.title_l10n
for l10n in self.l10n: for l10n in self.l10n:
if l10n.locale == current_locale: if l10n.locale == str(current_locale):
return l10n.title return l10n.title
return self.title_l10n return self.title_l10n
@ -188,15 +188,15 @@ class Account(db.Model):
if self.title_l10n is None: if self.title_l10n is None:
self.title_l10n = value self.title_l10n = value
return return
current_locale = str(get_locale()) current_locale: Locale = get_locale()
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: if current_locale == get_babel().instance.default_locale:
self.title_l10n = value self.title_l10n = value
return return
for l10n in self.l10n: for l10n in self.l10n:
if l10n.locale == current_locale: if l10n.locale == str(current_locale):
l10n.title = value l10n.title = value
return return
self.l10n.append(AccountL10n(locale=current_locale, title=value)) self.l10n.append(AccountL10n(locale=str(current_locale), title=value))
@property @property
def is_real(self) -> bool: def is_real(self) -> bool:
@ -214,6 +214,25 @@ class Account(db.Model):
""" """
return not self.is_real return not self.is_real
@property
def count(self) -> int:
"""Returns the number of items in the account.
:return: The number of items in the account.
"""
if not hasattr(self, "__count"):
setattr(self, "__count", 0)
return getattr(self, "__count")
@count.setter
def count(self, count: int) -> None:
"""Sets the number of items in the account.
:param count: The number of items in the account.
:return: None.
"""
setattr(self, "__count", count)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
"""Returns the values to be queried. """Returns the values to be queried.
@ -235,6 +254,16 @@ class Account(db.Model):
return True return True
return False return False
@property
def can_delete(self) -> bool:
"""Returns whether the account can be deleted.
:return: True if the account can be deleted, or False otherwise.
"""
if self.code in {"1111-001", "3351-001", "3353-001"}:
return False
return len(self.line_items) == 0
def delete(self) -> None: def delete(self) -> None:
"""Deletes this account. """Deletes this account.
@ -258,13 +287,15 @@ class Account(db.Model):
cls.no == int(m.group(2))).first() cls.no == int(m.group(2))).first()
@classmethod @classmethod
def debit(cls) -> list[t.Self]: def selectable_debit(cls) -> list[t.Self]:
"""Returns the debit accounts. """Returns the selectable debit accounts.
Payable line items can not start from debit.
:return: The debit accounts. :return: The selectable debit accounts.
""" """
return cls.query.filter(sa.or_(cls.base_code.startswith("1"), return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
cls.base_code.startswith("2"), sa.and_(cls.base_code.startswith("2"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("3"), cls.base_code.startswith("3"),
cls.base_code.startswith("5"), cls.base_code.startswith("5"),
cls.base_code.startswith("6"), cls.base_code.startswith("6"),
@ -279,12 +310,14 @@ class Account(db.Model):
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@classmethod @classmethod
def credit(cls) -> list[t.Self]: def selectable_credit(cls) -> list[t.Self]:
"""Returns the debit accounts. """Returns the selectable debit accounts.
Receivable line items can not start from credit.
:return: The debit accounts. :return: The selectable debit accounts.
""" """
return cls.query.filter(sa.or_(cls.base_code.startswith("1"), return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("2"), cls.base_code.startswith("2"),
cls.base_code.startswith("3"), cls.base_code.startswith("3"),
cls.base_code.startswith("4"), cls.base_code.startswith("4"),
@ -363,15 +396,16 @@ class Currency(db.Model):
l10n = db.relationship("CurrencyL10n", back_populates="currency", l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False) lazy=False)
"""The localized names.""" """The localized names."""
entries = db.relationship("JournalEntry", back_populates="currency") line_items = db.relationship("JournalEntryLineItem",
"""The journal entries.""" back_populates="currency")
"""The journal entry line items."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of the currency. """Returns the string representation of the currency.
:return: The string representation of the currency. :return: The string representation of the currency.
""" """
return f"{self.name} ({self.code})" return f"{self.name.title()} ({self.code})"
@property @property
def name(self) -> str: def name(self) -> str:
@ -379,11 +413,11 @@ class Currency(db.Model):
:return: The name in the current locale. :return: The name in the current locale.
""" """
current_locale = str(get_locale()) current_locale: Locale = get_locale()
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: if current_locale == get_babel().instance.default_locale:
return self.name_l10n return self.name_l10n
for l10n in self.l10n: for l10n in self.l10n:
if l10n.locale == current_locale: if l10n.locale == str(current_locale):
return l10n.name return l10n.name
return self.name_l10n return self.name_l10n
@ -397,15 +431,15 @@ class Currency(db.Model):
if self.name_l10n is None: if self.name_l10n is None:
self.name_l10n = value self.name_l10n = value
return return
current_locale = str(get_locale()) current_locale: Locale = get_locale()
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: if current_locale == get_babel().instance.default_locale:
self.name_l10n = value self.name_l10n = value
return return
for l10n in self.l10n: for l10n in self.l10n:
if l10n.locale == current_locale: if l10n.locale == str(current_locale):
l10n.name = value l10n.name = value
return return
self.l10n.append(CurrencyL10n(locale=current_locale, name=value)) self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
@property @property
def is_modified(self) -> bool: def is_modified(self) -> bool:
@ -420,6 +454,17 @@ class Currency(db.Model):
return True return True
return False return False
@property
def can_delete(self) -> bool:
"""Returns whether the currency can be deleted.
:return: True if the currency can be deleted, or False otherwise.
"""
from accounting.template_globals import default_currency_code
if self.code == default_currency_code():
return False
return len(self.line_items) == 0
def delete(self) -> None: def delete(self) -> None:
"""Deletes the currency. """Deletes the currency.
@ -447,23 +492,23 @@ class CurrencyL10n(db.Model):
"""The localized name.""" """The localized name."""
class TransactionCurrency: class JournalEntryCurrency:
"""A currency in a transaction.""" """A currency in a journal entry."""
def __init__(self, code: str, debit: list[JournalEntry], def __init__(self, code: str, debit: list[JournalEntryLineItem],
credit: list[JournalEntry]): credit: list[JournalEntryLineItem]):
"""Constructs the currency in the transaction. """Constructs the currency in the journal entry.
:param code: The currency code. :param code: The currency code.
:param debit: The debit entries. :param debit: The debit line items.
:param credit: The credit entries. :param credit: The credit line items.
""" """
self.code: str = code self.code: str = code
"""The currency code.""" """The currency code."""
self.debit: list[JournalEntry] = debit self.debit: list[JournalEntryLineItem] = debit
"""The debit entries.""" """The debit line items."""
self.credit: list[JournalEntry] = credit self.credit: list[JournalEntryLineItem] = credit
"""The credit entries.""" """The credit line items."""
@property @property
def name(self) -> str: def name(self) -> str:
@ -475,28 +520,28 @@ class TransactionCurrency:
@property @property
def debit_total(self) -> Decimal: def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries. """Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries. :return: The total amount of the debit line items.
""" """
return sum([x.amount for x in self.debit]) return sum([x.amount for x in self.debit])
@property @property
def credit_total(self) -> str: def credit_total(self) -> str:
"""Returns the total amount of the credit journal entries. """Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries. :return: The total amount of the credit line items.
""" """
return sum([x.amount for x in self.credit]) return sum([x.amount for x in self.credit])
class Transaction(db.Model): class JournalEntry(db.Model):
"""A transaction.""" """A journal entry."""
__tablename__ = "accounting_transactions" __tablename__ = "accounting_journal_entries"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False) autoincrement=False)
"""The transaction ID.""" """The journal entry ID."""
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False)
"""The date.""" """The date."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no = db.Column(db.Integer, nullable=False, default=text("1"))
@ -523,46 +568,50 @@ class Transaction(db.Model):
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator.""" """The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction") line_items = db.relationship("JournalEntryLineItem",
"""The journal entries.""" back_populates="journal_entry")
"""The line items."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of this transaction. """Returns the string representation of this journal entry.
:return: The string representation of this transaction. :return: The string representation of this journal entry.
""" """
if self.is_cash_expense: if self.is_cash_disbursement:
return gettext("Cash Expense Transaction#%(id)s", id=self.id) return gettext("Cash Disbursement Journal Entry#%(id)s",
if self.is_cash_income: id=self.id)
return gettext("Cash Income Transaction#%(id)s", id=self.id) if self.is_cash_receipt:
return gettext("Transfer Transaction#%(id)s", id=self.id) return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id)
return gettext("Transfer Journal Entry#%(id)s", id=self.id)
@property @property
def currencies(self) -> list[TransactionCurrency]: def currencies(self) -> list[JournalEntryCurrency]:
"""Returns the journal entries categorized by their currencies. """Returns the line items categorized by their currencies.
:return: The currency categories. :return: The currency categories.
""" """
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no) line_items: list[JournalEntryLineItem] = sorted(self.line_items,
key=lambda x: x.no)
codes: list[str] = [] codes: list[str] = []
by_currency: dict[str, list[JournalEntry]] = {} by_currency: dict[str, list[JournalEntryLineItem]] = {}
for entry in entries: for line_item in line_items:
if entry.currency_code not in by_currency: if line_item.currency_code not in by_currency:
codes.append(entry.currency_code) codes.append(line_item.currency_code)
by_currency[entry.currency_code] = [] by_currency[line_item.currency_code] = []
by_currency[entry.currency_code].append(entry) by_currency[line_item.currency_code].append(line_item)
return [TransactionCurrency(code=x, return [JournalEntryCurrency(code=x,
debit=[y for y in by_currency[x] debit=[y for y in by_currency[x]
if y.is_debit], if y.is_debit],
credit=[y for y in by_currency[x] credit=[y for y in by_currency[x]
if not y.is_debit]) if not y.is_debit])
for x in codes] for x in codes]
@property @property
def is_cash_income(self) -> bool: def is_cash_receipt(self) -> bool:
"""Returns whether this is a cash income transaction. """Returns whether this is a cash receipt journal entry.
:return: True if this is a cash income transaction, or False otherwise. :return: True if this is a cash receipt journal entry, or False
otherwise.
""" """
for currency in self.currencies: for currency in self.currencies:
if len(currency.debit) > 1: if len(currency.debit) > 1:
@ -572,10 +621,10 @@ class Transaction(db.Model):
return True return True
@property @property
def is_cash_expense(self) -> bool: def is_cash_disbursement(self) -> bool:
"""Returns whether this is a cash expense transaction. """Returns whether this is a cash disbursement journal entry.
:return: True if this is a cash expense transaction, or False :return: True if this is a cash disbursement journal entry, or False
otherwise. otherwise.
""" """
for currency in self.currencies: for currency in self.currencies:
@ -587,99 +636,84 @@ class Transaction(db.Model):
@property @property
def can_delete(self) -> bool: def can_delete(self) -> bool:
"""Returns whether the transaction can be deleted. """Returns whether the journal entry can be deleted.
:return: True if the transaction can be deleted, or False otherwise. :return: True if the journal entry can be deleted, or False otherwise.
""" """
if not hasattr(self, "__can_delete"): for line_item in self.line_items:
def has_offset() -> bool: if len(line_item.offsets) > 0:
for entry in self.entries:
if len(entry.offsets) > 0:
return True
return False return False
setattr(self, "__can_delete", not has_offset()) return True
return getattr(self, "__can_delete")
def delete(self) -> None: def delete(self) -> None:
"""Deletes the transaction. """Deletes the journal entry.
:return: None. :return: None.
""" """
JournalEntry.query\ JournalEntryLineItem.query\
.filter(JournalEntry.transaction_id == self.id).delete() .filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
db.session.delete(self) db.session.delete(self)
class JournalEntry(db.Model): class JournalEntryLineItem(db.Model):
"""An accounting journal entry.""" """A line item in the journal entry."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_journal_entry_line_items"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False) autoincrement=False)
"""The entry ID.""" """The line item ID."""
transaction_id = db.Column(db.Integer, journal_entry_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id, db.ForeignKey(JournalEntry.id,
onupdate="CASCADE", onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False) nullable=False)
"""The transaction ID.""" """The journal entry ID."""
transaction = db.relationship(Transaction, back_populates="entries") journal_entry = db.relationship(JournalEntry, back_populates="line_items")
"""The transaction.""" """The journal entry."""
is_debit = db.Column(db.Boolean, nullable=False) is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry.""" """True for a debit line item, or False for a credit line item."""
no = db.Column(db.Integer, nullable=False) no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit.""" """The line item number under the journal entry and debit or credit."""
original_entry_id = db.Column(db.Integer, original_line_item_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"), db.ForeignKey(id, onupdate="CASCADE"),
nullable=True) nullable=True)
"""The ID of the original entry.""" """The ID of the original line item."""
original_entry = db.relationship("JournalEntry", back_populates="offsets", original_line_item = db.relationship("JournalEntryLineItem",
remote_side=id, passive_deletes=True) remote_side=id, passive_deletes=True)
"""The original entry.""" """The original line item."""
offsets = db.relationship("JournalEntry", back_populates="original_entry")
"""The offset entries."""
currency_code = db.Column(db.String, currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"), db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False) nullable=False)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="entries") currency = db.relationship(Currency, back_populates="line_items")
"""The currency.""" """The currency."""
account_id = db.Column(db.Integer, account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, db.ForeignKey(Account.id,
onupdate="CASCADE"), onupdate="CASCADE"),
nullable=False) nullable=False)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="entries", lazy=False) account = db.relationship(Account, back_populates="line_items", lazy=False)
"""The account.""" """The account."""
summary = db.Column(db.String, nullable=True) description = db.Column(db.String, nullable=True)
"""The summary.""" """The description."""
amount = db.Column(db.Numeric(14, 2), nullable=False) amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount.""" """The amount."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of the journal entry. """Returns the string representation of the line item.
:return: The string representation of the journal entry. :return: The string representation of the line item.
""" """
if not hasattr(self, "__str"): if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount from accounting.template_filters import format_date, format_amount
setattr(self, "__str", setattr(self, "__str",
gettext("%(date)s %(summary)s %(amount)s", gettext("%(date)s %(description)s %(amount)s",
date=format_date(self.transaction.date), date=format_date(self.journal_entry.date),
summary="" if self.summary is None description="" if self.description is None
else self.summary, else self.description,
amount=format_amount(self.amount))) amount=format_amount(self.amount)))
return getattr(self, "__str") return getattr(self, "__str")
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
ID field, to work with WTForms.
:return: The journal entry ID.
"""
return self.id
@property @property
def account_code(self) -> str: def account_code(self) -> str:
"""Returns the account code. """Returns the account code.
@ -688,19 +722,11 @@ class JournalEntry(db.Model):
""" """
return self.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 @property
def is_need_offset(self) -> bool: def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset. """Returns whether the line item needs offset.
:return: True if the entry needs offset, or False otherwise. :return: True if the line item needs offset, or False otherwise.
""" """
if not self.account.is_need_offset: if not self.account.is_need_offset:
return False return False
@ -710,11 +736,19 @@ class JournalEntry(db.Model):
return False return False
return True return True
@property
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit line item.
"""
return self.amount if self.is_debit else None
@property @property
def credit(self) -> Decimal | None: def credit(self) -> Decimal | None:
"""Returns the credit amount. """Returns the credit amount.
:return: The credit amount, or None if this is not a credit entry. :return: The credit amount, or None if this is not a credit line item.
""" """
return None if self.is_debit else self.amount return None if self.is_debit else self.amount
@ -740,7 +774,41 @@ class JournalEntry(db.Model):
setattr(self, "__net_balance", net_balance) setattr(self, "__net_balance", net_balance)
@property @property
def query_values(self) -> tuple[list[str], list[str]]: def offsets(self) -> list[t.Self]:
"""Returns the offset items.
:return: The offset items.
"""
if not hasattr(self, "__offsets"):
cls: t.Type[t.Self] = self.__class__
offsets: list[t.Self] = cls.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
.order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all()
setattr(self, "__offsets", offsets)
return getattr(self, "__offsets")
@property
def match(self) -> t.Self | None:
"""Returns the match of the line item.
:return: The match of the line item.
"""
if not hasattr(self, "__match"):
setattr(self, "__match", None)
return getattr(self, "__match")
@match.setter
def match(self, match: t.Self) -> None:
"""Sets the match of the line item.
:param match: The matcho of the line item.
:return: None.
"""
setattr(self, "__match", match)
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried. """Returns the values to be queried.
:return: The values to be queried. :return: The values to be queried.
@ -750,12 +818,38 @@ class JournalEntry(db.Model):
frac: Decimal = (value - whole).normalize() frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:] return str(whole) + str(abs(frac))[1:]
txn_day: date = self.transaction.date return ["{}/{}/{}".format(self.journal_entry.date.year,
summary: str = "" if self.summary is None else self.summary self.journal_entry.date.month,
return ([summary], self.journal_entry.date.day),
[str(txn_day.year), "" if self.description is None else self.description,
"{}/{}".format(txn_day.year, txn_day.month), format_amount(self.amount)]
"{}/{}".format(txn_day.month, txn_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
format_amount(self.amount), class Option(db.Model):
format_amount(self.net_balance)]) """An option."""
__tablename__ = "accounting_options"
"""The table name."""
name = db.Column(db.String, nullable=False, primary_key=True)
"""The name."""
value = db.Column(db.Text, nullable=False)
"""The option value."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""

View File

@ -0,0 +1,30 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# 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 management.
"""
from flask import Blueprint
def init_app(bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .views import bp as option_bp
bp.register_blueprint(option_bp, url_prefix="/options")

View File

@ -0,0 +1,269 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the option management.
"""
from flask import render_template
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, FieldList, FormField, IntegerField
from wtforms.validators import DataRequired, ValidationError
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
IsDebitAccount, IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import Options
from accounting.utils.strip_text import strip_text
class CurrentAccountExists:
"""The validator to check that the current account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class AccountNotCurrent:
"""The validator to check that the account is a current account."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
return
if field.data[:2] not in {"11", "12", "21", "22"}:
raise ValidationError(lazy_gettext(
"This is not a current account."))
class NotStartPayableFromExpense:
"""The validator to check that a payable line item does not start from
expense."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data[0] != "2":
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"You cannot select a payable account as expense."))
class NotStartReceivableFromIncome:
"""The validator to check that a receivable line item does not start
from income."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data[0] != "1":
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"You cannot select a receivable account as income."))
class RecurringItemForm(FlaskForm):
"""The base sub-form to add or update the recurring item."""
no = IntegerField()
"""The order number of this recurring item."""
name = StringField()
"""The name of the recurring item."""
account_code = StringField()
"""The account code."""
description_template = StringField()
"""The description template."""
@property
def account_text(self) -> str | None:
"""Returns the account text.
:return: The account text.
"""
if self.account_code.data is None:
return None
account: Account | None = Account.find_by_code(self.account_code.data)
return None if account is None else str(account)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class RecurringExpenseForm(RecurringItemForm):
"""The sub-form to add or update the recurring expenses."""
no = IntegerField()
"""The order number of this recurring item."""
name = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
"""The name of the recurring item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(lazy_gettext("This account is not for expense.")),
NotStartPayableFromExpense()])
"""The account code."""
description_template = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please fill in the description template."))])
"""The template for the line item description."""
class RecurringIncomeForm(RecurringItemForm):
"""The sub-form to add or update the recurring incomes."""
no = IntegerField()
"""The order number of this recurring item."""
name = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
"""The name of the recurring item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(lazy_gettext("This account is not for income.")),
NotStartReceivableFromIncome()])
"""The account code."""
description_template = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please fill in the description template."))])
"""The description template."""
class RecurringForm(RecurringItemForm):
"""The sub-form for the recurring expenses and incomes."""
expenses = FieldList(FormField(RecurringExpenseForm), name="expense")
"""The recurring expenses."""
incomes = FieldList(FormField(RecurringIncomeForm), name="income")
"""The recurring incomes."""
@property
def item_template(self) -> str:
"""Returns the template of a recurring item.
:return: The template of a recurring item.
"""
return render_template(
"accounting/option/include/form-recurring-item.html",
expense_income="EXPENSE_INCOME",
item_index="ITEM_INDEX",
form=RecurringItemForm())
@property
def expense_accounts(self) -> list[Account]:
"""The expense accounts.
:return: None.
"""
return Account.selectable_debit()
@property
def income_accounts(self) -> list[Account]:
"""The income accounts.
:return: None.
"""
return Account.selectable_credit()
@property
def as_data(self) -> dict[str, list[tuple[str, str, str]]]:
"""Returns the form data.
:return: The form data.
"""
def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]:
return (item.name.data, item.account_code.data,
item.description_template.data)
expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
self.__sort_item_forms(expenses)
incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
self.__sort_item_forms(incomes)
return {"expense": [as_tuple(x) for x in expenses],
"income": [as_tuple(x) for x in incomes]}
@staticmethod
def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
"""Sorts the recurring item sub-forms.
:param forms: The recurring item sub-forms.
:return: None.
"""
ord_by_form: dict[RecurringItemForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
ord_by_form.get(x)))
class OptionForm(FlaskForm):
"""The form to update the options."""
default_currency_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext("Please select the default currency.")),
CurrencyExists()])
"""The default currency code."""
default_ie_account_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please select the default account"
" for the income and expenses log.")),
CurrentAccountExists(),
AccountNotCurrent()])
"""The default account code for the income and expenses log."""
recurring = FormField(RecurringForm)
"""The recurring expenses and incomes."""
def populate_obj(self, obj: Options) -> None:
"""Populates the form data into a currency object.
:param obj: The currency object.
:return: None.
"""
obj.default_currency_code = self.default_currency_code.data
obj.default_ie_account_code = self.default_ie_account_code.data
obj.recurring_data = self.recurring.form.as_data
@property
def current_accounts(self) -> list[CurrentAccount]:
"""Returns the current accounts.
:return: The current accounts.
"""
return CurrentAccount.accounts()

View File

@ -0,0 +1,83 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# 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 option management.
"""
from urllib.parse import parse_qsl, urlencode
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting.locale import lazy_gettext
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_admin
from .forms import OptionForm
bp: Blueprint = Blueprint("option", __name__)
"""The view blueprint for the currency management."""
@bp.get("", endpoint="detail")
@has_permission(can_admin)
def show_options() -> str:
"""Shows the options.
:return: The options.
"""
return render_template("accounting/option/detail.html", obj=options)
@bp.get("edit", endpoint="edit")
@has_permission(can_admin)
def show_option_form() -> str:
"""Shows the option form.
:return: The option form.
"""
form: OptionForm
if "form" in session:
form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = OptionForm(obj=options)
return render_template("accounting/option/form.html", form=form)
@bp.post("update", endpoint="update")
@has_permission(can_admin)
def update_options() -> redirect:
"""Updates the options.
:return: The redirection to the option form.
"""
form = OptionForm(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.option.edit")))
form.populate_obj(options)
if not options.is_modified:
flash(s(lazy_gettext("The settings were not modified.")), "success")
return redirect(inherit_next(url_for("accounting.option.detail")))
options.commit()
flash(s(lazy_gettext("The settings are saved successfully.")), "success")
return redirect(inherit_next(url_for("accounting.option.detail")))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -17,19 +17,21 @@
"""The report management. """The report management.
""" """
from flask import Flask, Blueprint from flask import Flask
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, url_prefix: str) -> None:
"""Initialize the application. """Initialize the application.
:param app: The Flask application. :param app: The Flask application.
:param bp: The blueprint of the accounting application. :param url_prefix: The URL prefix of the accounting application.
:return: None. :return: None.
""" """
from .converters import PeriodConverter, IncomeExpensesAccountConverter from .converters import PeriodConverter, CurrentAccountConverter, \
NeedOffsetAccountConverter
app.url_map.converters["period"] = PeriodConverter app.url_map.converters["period"] = PeriodConverter
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter app.url_map.converters["currentAccount"] = CurrentAccountConverter
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
from .views import bp as report_bp from .views import bp as report_bp
bp.register_blueprint(report_bp, url_prefix="/reports") app.register_blueprint(report_bp, url_prefix=url_prefix)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -23,13 +23,13 @@ from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import Account from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from .period import Period, get_period from .period import Period, get_period
from .utils.ie_account import IncomeExpensesAccount
class PeriodConverter(BaseConverter): class PeriodConverter(BaseConverter):
"""The supplier converter to convert the period specification from and to """The converter to convert the period specification from and to the
the corresponding period in the routes.""" corresponding period in the routes."""
def to_python(self, value: str) -> Period: def to_python(self, value: str) -> Period:
"""Converts a period specification to a period. """Converts a period specification to a period.
@ -51,26 +51,52 @@ class PeriodConverter(BaseConverter):
return value.spec return value.spec
class IncomeExpensesAccountConverter(BaseConverter): class CurrentAccountConverter(BaseConverter):
"""The supplier converter to convert the income and expenses log pseudo """The converter to convert the current account code from and to the
account code from and to the corresponding pseudo account in the routes.""" corresponding account in the routes."""
def to_python(self, value: str) -> IncomeExpensesAccount: def to_python(self, value: str) -> CurrentAccount:
"""Converts an account code to an account. """Converts an account code to an account.
:param value: The account code. :param value: The account code.
:return: The corresponding account. :return: The corresponding account.
""" """
if value == IncomeExpensesAccount.CURRENT_AL_CODE: if value == CurrentAccount.CURRENT_AL_CODE:
return IncomeExpensesAccount.current_assets_and_liabilities() return CurrentAccount.current_assets_and_liabilities()
if not re.match("^[12][12]", value): if not re.match("^[12][12]", value):
abort(404) abort(404)
account: Account | None = Account.find_by_code(value) account: Account | None = Account.find_by_code(value)
if account is None: if account is None:
abort(404) abort(404)
return IncomeExpensesAccount(account) return CurrentAccount(account)
def to_url(self, value: IncomeExpensesAccount) -> str: def to_url(self, value: CurrentAccount) -> str:
"""Converts an account to account code.
:param value: The account.
:return: Its code.
"""
return value.code
class NeedOffsetAccountConverter(BaseConverter):
"""The converter to convert the unapplied original line item account code
from and to the corresponding account in the routes."""
def to_python(self, value: str) -> Account:
"""Converts an account code to an account.
:param value: The account code.
:return: The corresponding account.
"""
account: Account | None = Account.find_by_code(value)
if account is None:
abort(404)
if not account.is_need_offset:
abort(404)
return account
def to_url(self, value: Account) -> str:
"""Converts an account to account code. """Converts an account to account code.
:param value: The account. :param value: The account.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

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

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -35,7 +35,7 @@ class ThisMonth(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "this-month" self.spec = "this-month"
self.desc = gettext("This month") self.desc = gettext("This Month")
self.is_a_month = True self.is_a_month = True
self.is_type_month = True self.is_type_month = True
@ -55,7 +55,7 @@ class LastMonth(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "last-month" self.spec = "last-month"
self.desc = gettext("Last month") self.desc = gettext("Last Month")
self.is_a_month = True self.is_a_month = True
self.is_type_month = True self.is_type_month = True
@ -75,7 +75,7 @@ class SinceLastMonth(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "since-last-month" self.spec = "since-last-month"
self.desc = gettext("Since last month") self.desc = gettext("Since Last Month")
self.is_type_month = True self.is_type_month = True
@ -90,7 +90,7 @@ class ThisYear(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "this-year" self.spec = "this-year"
self.desc = gettext("This year") self.desc = gettext("This Year")
self.is_a_year = True self.is_a_year = True
@ -105,7 +105,7 @@ class LastYear(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "last-year" self.spec = "last-year"
self.desc = gettext("Last year") self.desc = gettext("Last Year")
self.is_a_year = True self.is_a_year = True

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -24,8 +24,8 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \ from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntry JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -124,19 +124,20 @@ class AccountCollector:
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}] = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)).label("balance") else_=-JournalEntryLineItem.amount)).label("balance")
select_balance: sa.Select \ select_balance: sa.Select \
= sa.select(Account.id, Account.base_code, Account.no, = sa.select(Account.id, Account.base_code, Account.no,
balance_func)\ balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\ .group_by(Account.id, Account.base_code, Account.no)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \ account_balances: list[sa.Row] \
= db.session.execute(select_balance).all() = db.session.execute(select_balance).all()
@ -178,8 +179,8 @@ class AccountCollector:
if self.__period.start is None: if self.__period.start is None:
return None return None
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
Transaction.date < self.__period.start] JournalEntry.date < self.__period.start]
return self.__query_balance(conditions) return self.__query_balance(conditions)
def __add_current_period(self) -> None: def __add_current_period(self) -> None:
@ -197,11 +198,11 @@ class AccountCollector:
:return: The net income or loss for current period. :return: The net income or loss for current period.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return self.__query_balance(conditions) return self.__query_balance(conditions)
@staticmethod @staticmethod
@ -215,10 +216,10 @@ class AccountCollector:
conditions.extend([sa.not_(Account.base_code.startswith(x)) conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2", "3"}]) for x in {"1", "2", "3"}])
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)) else_=-JournalEntryLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\ select_balance: sa.Select = sa.select(balance_func)\
.join(Transaction).join(Account).filter(*conditions) .join(JournalEntry).join(Account).filter(*conditions)
return db.session.scalar(select_balance) return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None, def __add_owner_s_equity(self, code: str, amount: Decimal | None,

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -26,39 +26,40 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec period_spec
from accounting.report.utils.ie_account import IncomeExpensesAccount
from accounting.report.utils.option_link import OptionLink from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url from accounting.report.utils.urls import income_expenses_url
from accounting.utils.cast import be from accounting.utils.cast import be
from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
class ReportEntry: class ReportLineItem:
"""An entry in the report.""" """A line item in the report."""
def __init__(self, entry: JournalEntry | None = None): def __init__(self, line_item: JournalEntryLineItem | None = None):
"""Constructs the entry in the report. """Constructs the line item in the report.
:param entry: The journal entry. :param line_item: The journal entry line item.
""" """
self.is_brought_forward: bool = False self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total entry.""" """Whether this is the total line item."""
self.date: date | None = None self.date: date | None = None
"""The date.""" """The date."""
self.account: Account | None = None self.account: Account | None = None
"""The account.""" """The account."""
self.summary: str | None = None self.description: str | None = None
"""The summary.""" """The description."""
self.income: Decimal | None = None self.income: Decimal | None = None
"""The income amount.""" """The income amount."""
self.expense: Decimal | None = None self.expense: Decimal | None = None
@ -68,24 +69,24 @@ class ReportEntry:
self.note: str | None = None self.note: str | None = None
"""The note.""" """The note."""
self.url: str | None = None self.url: str | None = None
"""The URL to the journal entry.""" """The URL to the journal entry line item."""
if entry is not None: if line_item is not None:
self.date = entry.transaction.date self.date = line_item.journal_entry.date
self.account = entry.account self.account = line_item.account
self.summary = entry.summary self.description = line_item.description
self.income = None if entry.is_debit else entry.amount self.income = None if line_item.is_debit else line_item.amount
self.expense = entry.amount if entry.is_debit else None self.expense = line_item.amount if line_item.is_debit else None
self.note = entry.transaction.note self.note = line_item.journal_entry.note
self.url = url_for("accounting.transaction.detail", self.url = url_for("accounting.journal-entry.detail",
txn=entry.transaction) journal_entry=line_item.journal_entry)
class EntryCollector: class LineItemCollector:
"""The report entry collector.""" """The line item collector."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount, def __init__(self, currency: Currency, account: CurrentAccount,
period: Period): period: Period):
"""Constructs the report entry collector. """Constructs the line item collector.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
@ -93,148 +94,150 @@ class EntryCollector:
""" """
self.__currency: Currency = currency self.__currency: Currency = currency
"""The currency.""" """The currency."""
self.__account: IncomeExpensesAccount = account self.__account: CurrentAccount = account
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period""" """The period"""
self.brought_forward: ReportEntry | None self.brought_forward: ReportLineItem | None
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] self.line_items: list[ReportLineItem]
"""The log entries.""" """The line items."""
self.total: ReportEntry | None self.total: ReportLineItem | None
"""The total entry.""" """The total line item."""
self.brought_forward = self.__get_brought_forward_entry() self.brought_forward = self.__get_brought_forward()
self.entries = self.__query_entries() self.line_items = self.__query_line_items()
self.total = self.__get_total_entry() self.total = self.__get_total()
self.__populate_balance() self.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None: def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward entry. """Queries, composes and returns the brought-forward line item.
:return: The brought-forward entry, or None if the period starts from :return: The brought-forward line item, or None if the period starts
the beginning. from the beginning.
""" """
if self.__period.start is None: if self.__period.start is None:
return None return None
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select = sa.Select(balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(be(JournalEntry.currency_code == self.__currency.code), .filter(be(JournalEntryLineItem.currency_code
== self.__currency.code),
self.__account_condition, self.__account_condition,
Transaction.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
if balance is None: if balance is None:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_brought_forward = True line_item.is_brought_forward = True
entry.date = self.__period.start line_item.date = self.__period.start
entry.account = Account.accumulated_change() line_item.account = Account.accumulated_change()
entry.summary = gettext("Brought forward") line_item.description = gettext("Brought forward")
if balance > 0: if balance > 0:
entry.income = balance line_item.income = balance
elif balance < 0: elif balance < 0:
entry.expense = -balance line_item.expense = -balance
entry.balance = balance line_item.balance = balance
return entry return line_item
def __query_entries(self) -> list[ReportEntry]: def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the log entries. """Queries and returns the line items.
:return: The log entries. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition] self.__account_condition]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
txn_with_account: sa.Select = sa.Select(Transaction.id).\ journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntry).join(Account).filter(*conditions) join(JournalEntryLineItem).join(Account).filter(*conditions)
return [ReportEntry(x) return [ReportLineItem(x)
for x in JournalEntry.query.join(Transaction).join(Account) for x in JournalEntryLineItem.query
.filter(JournalEntry.transaction_id.in_(txn_with_account), .join(JournalEntry).join(Account)
JournalEntry.currency_code == self.__currency.code, .filter(JournalEntryLineItem.journal_entry_id
.in_(journal_entry_with_account),
JournalEntryLineItem.currency_code
== self.__currency.code,
sa.not_(self.__account_condition)) sa.not_(self.__account_condition))
.order_by(Transaction.date, .order_by(JournalEntry.date,
Transaction.no, JournalEntry.no,
JournalEntry.is_debit, JournalEntryLineItem.is_debit,
JournalEntry.no) JournalEntryLineItem.no)
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntry.transaction))] selectinload(JournalEntryLineItem.journal_entry))]
@property @property
def __account_condition(self) -> sa.BinaryExpression: def __account_condition(self) -> sa.BinaryExpression:
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE: if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
return sa.or_(Account.base_code.startswith("11"), return CurrentAccount.sql_condition()
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22"))
return Account.id == self.__account.id return Account.id == self.__account.id
def __get_total_entry(self) -> ReportEntry | None: def __get_total(self) -> ReportLineItem | None:
"""Composes the total entry. """Composes the total line item.
:return: The total entry, or None if there is no data. :return: The total line item, or None if there is no data.
""" """
if self.brought_forward is None and len(self.entries) == 0: if self.brought_forward is None and len(self.line_items) == 0:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_total = True line_item.is_total = True
entry.summary = gettext("Total") line_item.description = gettext("Total")
entry.income = sum([x.income for x in self.entries line_item.income = sum([x.income for x in self.line_items
if x.income is not None]) if x.income is not None])
entry.expense = sum([x.expense for x in self.entries line_item.expense = sum([x.expense for x in self.line_items
if x.expense is not None]) if x.expense is not None])
entry.balance = entry.income - entry.expense line_item.balance = line_item.income - line_item.expense
if self.brought_forward is not None: if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance line_item.balance \
return entry = self.brought_forward.balance + line_item.balance
return line_item
def __populate_balance(self) -> None: def __populate_balance(self) -> None:
"""Populates the balance of the entries. """Populates the balance of the line items.
:return: None. :return: None.
""" """
balance: Decimal = 0 if self.brought_forward is None \ balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance else self.brought_forward.balance
for entry in self.entries: for line_item in self.line_items:
if entry.income is not None: if line_item.income is not None:
balance = balance + entry.income balance = balance + line_item.income
if entry.expense is not None: if line_item.expense is not None:
balance = balance - entry.expense balance = balance - line_item.expense
entry.balance = balance line_item.balance = balance
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, txn_date: date | str | None, def __init__(self, journal_entry_date: date | str | None,
account: str | None, account: str | None,
summary: str | None, description: str | None,
income: str | Decimal | None, income: str | Decimal | None,
expense: str | Decimal | None, expense: str | Decimal | None,
balance: str | Decimal | None, balance: str | Decimal | None,
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param txn_date: The transaction date. :param journal_entry_date: The journal entry date.
:param account: The account. :param account: The account.
:param summary: The summary. :param description: The description.
:param income: The income. :param income: The income.
:param expense: The expense. :param expense: The expense.
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = txn_date self.date: date | str | None = journal_entry_date
"""The date.""" """The date."""
self.account: str | None = account self.account: str | None = account
"""The account.""" """The account."""
self.summary: str | None = summary self.description: str | None = description
"""The summary.""" """The description."""
self.income: str | Decimal | None = income self.income: str | Decimal | None = income
"""The income.""" """The income."""
self.expense: str | Decimal | None = expense self.expense: str | Decimal | None = expense
@ -250,7 +253,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row. :return: The values of the row.
""" """
return [self.date, self.account, self.summary, return [self.date, self.account, self.description,
self.income, self.expense, self.balance, self.note] self.income, self.expense, self.balance, self.note]
@ -258,39 +261,39 @@ class PageParams(BasePageParams):
"""The HTML page parameters.""" """The HTML page parameters."""
def __init__(self, currency: Currency, def __init__(self, currency: Currency,
account: IncomeExpensesAccount, account: CurrentAccount,
period: Period, period: Period,
has_data: bool, has_data: bool,
pagination: Pagination[ReportEntry], pagination: Pagination[ReportLineItem],
brought_forward: ReportEntry | None, brought_forward: ReportLineItem | None,
entries: list[ReportEntry], line_items: list[ReportLineItem],
total: ReportEntry | None): total: ReportLineItem | None):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
:param period: The period. :param period: The period.
:param has_data: True if there is any data, or False otherwise. :param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry. :param brought_forward: The brought-forward line item.
:param entries: The log entries. :param line_items: The line items.
:param total: The total entry. :param total: The total line item.
""" """
self.currency: Currency = currency self.currency: Currency = currency
"""The currency.""" """The currency."""
self.account: IncomeExpensesAccount = account self.account: CurrentAccount = account
"""The account.""" """The account."""
self.period: Period = period self.period: Period = period
"""The period.""" """The period."""
self.__has_data: bool = has_data self.__has_data: bool = has_data
"""True if there is any data, or False otherwise.""" """True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportEntry] = pagination self.pagination: Pagination[ReportLineItem] = pagination
"""The pagination.""" """The pagination."""
self.brought_forward: ReportEntry | None = brought_forward self.brought_forward: ReportLineItem | None = brought_forward
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] = entries self.line_items: list[ReportLineItem] = line_items
"""The report entries.""" """The line items."""
self.total: ReportEntry | None = total self.total: ReportLineItem | None = total
"""The total entry.""" """The total line item."""
self.period_chooser: PeriodChooser = PeriodChooser( self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_expenses_url(currency, account, x)) lambda x: income_expenses_url(currency, account, x))
"""The period chooser.""" """The period chooser."""
@ -335,25 +338,23 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
current_al: IncomeExpensesAccount \ current_al: CurrentAccount \
= IncomeExpensesAccount.current_assets_and_liabilities() = CurrentAccount.current_assets_and_liabilities()
options: list[OptionLink] \ options: list[OptionLink] \
= [OptionLink(str(current_al), = [OptionLink(str(current_al),
income_expenses_url(self.currency, current_al, income_expenses_url(self.currency, current_al,
self.period), self.period),
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntry.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\ .join(Account)\
.filter(be(JournalEntry.currency_code == self.currency.code), .filter(be(JournalEntryLineItem.currency_code
sa.or_(Account.base_code.startswith("11"), == self.currency.code),
Account.base_code.startswith("12"), CurrentAccount.sql_condition())\
Account.base_code.startswith("21"), .group_by(JournalEntryLineItem.account_id)
Account.base_code.startswith("22")))\
.group_by(JournalEntry.account_id)
options.extend([OptionLink(str(x), options.extend([OptionLink(str(x),
income_expenses_url( income_expenses_url(
self.currency, self.currency,
IncomeExpensesAccount(x), CurrentAccount(x),
self.period), self.period),
x.id == self.account.id) x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use)) for x in Account.query.filter(Account.id.in_(in_use))
@ -364,7 +365,7 @@ class PageParams(BasePageParams):
class IncomeExpenses(BaseReport): class IncomeExpenses(BaseReport):
"""The income and expenses log.""" """The income and expenses log."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount, def __init__(self, currency: Currency, account: CurrentAccount,
period: Period): period: Period):
"""Constructs an income and expenses log. """Constructs an income and expenses log.
@ -374,18 +375,19 @@ class IncomeExpenses(BaseReport):
""" """
self.__currency: Currency = currency self.__currency: Currency = currency
"""The currency.""" """The currency."""
self.__account: IncomeExpensesAccount = account self.__account: CurrentAccount = account
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period.""" """The period."""
collector: EntryCollector = EntryCollector( collector: LineItemCollector = LineItemCollector(
self.__currency, self.__account, self.__period) self.__currency, self.__account, self.__period)
self.__brought_forward: ReportEntry | None = collector.brought_forward self.__brought_forward: ReportLineItem | None \
"""The brought-forward entry.""" = collector.brought_forward
self.__entries: list[ReportEntry] = collector.entries """The brought-forward line item."""
"""The report entries.""" self.__line_items: list[ReportLineItem] = collector.line_items
self.__total: ReportEntry | None = collector.total """The line items."""
"""The total entry.""" self.__total: ReportLineItem | None = collector.total
"""The total line item."""
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -403,20 +405,20 @@ class IncomeExpenses(BaseReport):
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"), rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
gettext("Summary"), gettext("Income"), gettext("Description"), gettext("Income"),
gettext("Expense"), gettext("Balance"), gettext("Expense"), gettext("Balance"),
gettext("Note"))] gettext("Note"))]
if self.__brought_forward is not None: if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date, rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account).title(), str(self.__brought_forward.account).title(),
self.__brought_forward.summary, self.__brought_forward.description,
self.__brought_forward.income, self.__brought_forward.income,
self.__brought_forward.expense, self.__brought_forward.expense,
self.__brought_forward.balance, self.__brought_forward.balance,
None)) None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary, rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
x.income, x.expense, x.balance, x.note) x.income, x.expense, x.balance, x.note)
for x in self.__entries]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, None, rows.append(CSVRow(gettext("Total"), None, None,
self.__total.income, self.__total.expense, self.__total.income, self.__total.expense,
@ -428,31 +430,31 @@ class IncomeExpenses(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
all_entries: list[ReportEntry] = [] all_line_items: list[ReportLineItem] = []
if self.__brought_forward is not None: if self.__brought_forward is not None:
all_entries.append(self.__brought_forward) all_line_items.append(self.__brought_forward)
all_entries.extend(self.__entries) all_line_items.extend(self.__line_items)
if self.__total is not None: if self.__total is not None:
all_entries.append(self.__total) all_line_items.append(self.__total)
pagination: Pagination[ReportEntry] \ pagination: Pagination[ReportLineItem] \
= Pagination[ReportEntry](all_entries, is_reversed=True) = Pagination[ReportLineItem](all_line_items, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list page_line_items: list[ReportLineItem] = pagination.list
has_data: bool = len(page_entries) > 0 has_data: bool = len(page_line_items) > 0
brought_forward: ReportEntry | None = None brought_forward: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward: if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
brought_forward = page_entries[0] brought_forward = page_line_items[0]
page_entries = page_entries[1:] page_line_items = page_line_items[1:]
total: ReportEntry | None = None total: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[-1].is_total: if len(page_line_items) > 0 and page_line_items[-1].is_total:
total = page_entries[-1] total = page_line_items[-1]
page_entries = page_entries[:-1] page_line_items = page_line_items[:-1]
params: PageParams = PageParams(currency=self.__currency, params: PageParams = PageParams(currency=self.__currency,
account=self.__account, account=self.__account,
period=self.__period, period=self.__period,
has_data=has_data, has_data=has_data,
pagination=pagination, pagination=pagination,
brought_forward=brought_forward, brought_forward=brought_forward,
entries=page_entries, line_items=page_line_items,
total=total) total=total)
return render_template("accounting/report/income-expenses.html", return render_template("accounting/report/income-expenses.html",
report=params) report=params)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -24,8 +24,8 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \ from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntry JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -256,19 +256,20 @@ class IncomeStatement(BaseReport):
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)] = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount), (JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
else_=JournalEntry.amount)).label("balance") else_=JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all() balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -24,7 +24,8 @@ from flask import Response, render_template
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -178,18 +179,19 @@ class TrialBalance(BaseReport):
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)).label("balance") else_=-JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all() balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \

View File

@ -0,0 +1,185 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 unapplied original line items.
"""
from datetime import date
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Account, JournalEntryLineItem
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
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.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
from accounting.utils.offset_matcher import OffsetMatcher
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_edit
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | date, currency: str,
description: str | None, amount: str | Decimal,
net_balance: str | Decimal):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param description: The description.
:param amount: The amount.
:param net_balance: The net balance.
"""
self.date: str | date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.description: str | None = description
"""The description."""
self.amount: str | Decimal = amount
"""The amount."""
self.net_balance: str | Decimal = net_balance
"""The net balance."""
@property
def values(self) -> list[str | date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.description, self.amount,
self.net_balance]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, account: Account,
is_mark_matches: bool,
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param account: The account.
:param is_mark_matches: Whether to mark the matched offsets.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.account: Account = account
"""The account."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
self.is_mark_matches: bool = is_mark_matches
"""Whether to mark the matched offsets."""
@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.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNAPPLIED,
account=self.account)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
unapplied_url(None),
False)]
options.extend([OptionLink(str(x),
unapplied_url(x),
x.id == self.account.id)
for x in get_accounts_with_unapplied()])
return options
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param line_items: The line items.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Description"), gettext("Amount"),
gettext("Net Balance"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
x.description, x.amount, x.net_balance)
for x in line_items])
return rows
class UnappliedOriginalLineItems(BaseReport):
"""The unapplied original line items."""
def __init__(self, account: Account):
"""Constructs the unapplied original line items.
:param account: The account.
"""
self.__account: Account = account
"""The account."""
offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
self.__line_items: list[JournalEntryLineItem] \
= offset_matcher.unapplied
"""The line items."""
self.__is_mark_matches: bool \
= can_edit() and len(offset_matcher.unmatched_offsets) > 0
"""Whether to mark the matched offsets."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"unapplied-{self.__account.code}.csv"
return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(account=self.__account,
is_mark_matches=self.__is_mark_matches,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unapplied.html",
report=params)

View File

@ -0,0 +1,137 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 accounts with unapplied original line items.
"""
from datetime import date
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Account
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
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.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, account: str, count: int | str):
"""Constructs a row in the CSV.
:param account: The account.
:param count: The number of unapplied original line items.
"""
self.account: str = account
"""The currency."""
self.count: int | str = count
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.account, self.count]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param accounts: The accounts.
"""
self.accounts: list[Account] = accounts
"""The accounts."""
@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.UNAPPLIED)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
unapplied_url(None),
True)]
options.extend([OptionLink(str(x),
unapplied_url(x),
False)
for x in self.accounts])
return options
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param accounts: The accounts.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
return rows
class AccountsWithUnappliedOriginalLineItems(BaseReport):
"""The accounts with unapplied original line items."""
def __init__(self):
"""Constructs the outstanding balances."""
self.__accounts: list[Account] = get_accounts_with_unapplied()
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"unapplied-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
return render_template("accounting/report/unapplied-accounts.html",
report=PageParams(accounts=self.__accounts))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

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

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -22,6 +22,7 @@ from abc import ABC, abstractmethod
from datetime import timedelta, date from datetime import timedelta, date
from decimal import Decimal from decimal import Decimal
from io import StringIO from io import StringIO
from urllib.parse import quote
from flask import Response from flask import Response
@ -53,7 +54,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
fp.seek(0) fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv") response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \ response.headers["Content-Disposition"] \
= f"attachment; filename={filename}" = f"attachment; filename={quote(filename)}"
return response return response

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -30,11 +30,11 @@ from accounting.locale import gettext
from accounting.models import Currency, Account from accounting.models import Currency, Account
from accounting.report.period import Period, get_period from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount from accounting.utils.current_account import CurrentAccount
from .option_link import OptionLink from .option_link import OptionLink
from .report_type import ReportType from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \ from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url
class ReportChooser: class ReportChooser:
@ -68,12 +68,13 @@ class ReportChooser:
"""The title of the current report.""" """The title of the current report."""
self.is_search: bool = active_report == ReportType.SEARCH self.is_search: bool = active_report == ReportType.SEARCH
"""Whether the current report is the search page.""" """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.__income_expenses)
self.__reports.append(self.__ledger)
self.__reports.append(self.__journal)
self.__reports.append(self.__trial_balance) self.__reports.append(self.__trial_balance)
self.__reports.append(self.__income_statement) self.__reports.append(self.__income_statement)
self.__reports.append(self.__balance_sheet) self.__reports.append(self.__balance_sheet)
self.__reports.append(self.__unapplied)
for report in self.__reports: for report in self.__reports:
if report.is_active: if report.is_active:
self.current_report = report.title self.current_report = report.title
@ -81,14 +82,20 @@ class ReportChooser:
self.current_report = gettext("Search") self.current_report = gettext("Search")
@property @property
def __journal(self) -> OptionLink: def __income_expenses(self) -> OptionLink:
"""Returns the journal. """Returns the income and expenses log.
:return: The journal. :return: The income and expenses log.
""" """
return OptionLink(gettext("Journal"), journal_url(self.__period), account: Account = self.__account
self.__active_report == ReportType.JOURNAL, if not re.match(r"[12][12]", account.base_code):
fa_icon="fa-solid fa-book") account: Account = Account.cash()
return OptionLink(gettext("Income and Expenses Log"),
income_expenses_url(self.__currency,
CurrentAccount(account),
self.__period),
self.__active_report == ReportType.INCOME_EXPENSES,
fa_icon="fa-solid fa-money-bill-wave")
@property @property
def __ledger(self) -> OptionLink: def __ledger(self) -> OptionLink:
@ -103,20 +110,14 @@ class ReportChooser:
fa_icon="fa-solid fa-clipboard") fa_icon="fa-solid fa-clipboard")
@property @property
def __income_expenses(self) -> OptionLink: def __journal(self) -> OptionLink:
"""Returns the income and expenses log. """Returns the journal.
:return: The income and expenses log. :return: The journal.
""" """
account: Account = self.__account return OptionLink(gettext("Journal"), journal_url(self.__period),
if not re.match(r"[12][12]", account.base_code): self.__active_report == ReportType.JOURNAL,
account: Account = Account.cash() fa_icon="fa-solid fa-book")
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 @property
def __trial_balance(self) -> OptionLink: def __trial_balance(self) -> OptionLink:
@ -151,6 +152,23 @@ class ReportChooser:
self.__active_report == ReportType.BALANCE_SHEET, self.__active_report == ReportType.BALANCE_SHEET,
fa_icon="fa-solid fa-scale-balanced") fa_icon="fa-solid fa-scale-balanced")
@property
def __unapplied(self) -> OptionLink:
"""Returns the unapplied original line items.
:return: The unapplied original line items.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(None),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(account),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
def __iter__(self) -> t.Iterator[OptionLink]: def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports. """Returns the iteration of the reports.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -34,5 +34,7 @@ class ReportType(Enum):
"""The income statement.""" """The income statement."""
BALANCE_SHEET: str = "balance-sheet" BALANCE_SHEET: str = "balance-sheet"
"""The balance sheet.""" """The balance sheet."""
UNAPPLIED: str = "unapplied"
"""The unapplied original line items."""
SEARCH: str = "search" SEARCH: str = "search"
"""The search.""" """The search."""

View File

@ -0,0 +1,67 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 unapplied original line item utilities.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied() -> list[Account]:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_unapplied: sa.Select \
= sa.select(JournalEntryLineItem.id)\
.join(Account)\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\
.filter(Account.is_need_offset,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit)))\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.join(JournalEntryLineItem, isouter=True)\
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
.group_by(Account.id)\
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
for account in accounts:
account.count = counts[account.id]
return accounts

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -22,7 +22,8 @@ from flask import url_for
from accounting.models import Currency, Account from accounting.models import Currency, Account
from accounting.report.period import Period from accounting.report.period import Period
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount, default_ie_account_code from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import options
def journal_url(period: Period) \ def journal_url(period: Period) \
@ -33,8 +34,8 @@ def journal_url(period: Period) \
:return: The URL of the journal. :return: The URL of the journal.
""" """
if period.is_default: if period.is_default:
return url_for("accounting.report.journal-default") return url_for("accounting-report.journal-default")
return url_for("accounting.report.journal", period=period) return url_for("accounting-report.journal", period=period)
def ledger_url(currency: Currency, account: Account, period: Period) \ def ledger_url(currency: Currency, account: Account, period: Period) \
@ -46,15 +47,16 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
:param period: The period. :param period: The period.
:return: The URL of the ledger. :return: The URL of the ledger.
""" """
if period.is_default: if currency.code == default_currency_code() \
return url_for("accounting.report.ledger-default", and account.code == Account.CASH_CODE \
currency=currency, account=account) and period.is_default:
return url_for("accounting.report.ledger", return url_for("accounting-report.ledger-default")
return url_for("accounting-report.ledger",
currency=currency, account=account, currency=currency, account=account,
period=period) period=period)
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount, def income_expenses_url(currency: Currency, account: CurrentAccount,
period: Period) -> str: period: Period) -> str:
"""Returns the URL of an income and expenses log. """Returns the URL of an income and expenses log.
@ -64,13 +66,10 @@ def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
:return: The URL of the income and expenses log. :return: The URL of the income and expenses log.
""" """
if currency.code == default_currency_code() \ if currency.code == default_currency_code() \
and account.code == default_ie_account_code() \ and account.code == options.default_ie_account_code \
and period.is_default: and period.is_default:
return url_for("accounting.report.default") return url_for("accounting-report.default")
if period.is_default: return url_for("accounting-report.income-expenses",
return url_for("accounting.report.income-expenses-default",
currency=currency, account=account)
return url_for("accounting.report.income-expenses",
currency=currency, account=account, currency=currency, account=account,
period=period) period=period)
@ -82,10 +81,9 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
:param period: The period. :param period: The period.
:return: The URL of the trial balance. :return: The URL of the trial balance.
""" """
if period.is_default: if currency.code == default_currency_code() and period.is_default:
return url_for("accounting.report.trial-balance-default", return url_for("accounting-report.trial-balance-default")
currency=currency) return url_for("accounting-report.trial-balance",
return url_for("accounting.report.trial-balance",
currency=currency, period=period) currency=currency, period=period)
@ -96,10 +94,9 @@ def income_statement_url(currency: Currency, period: Period) -> str:
:param period: The period. :param period: The period.
:return: The URL of the income statement. :return: The URL of the income statement.
""" """
if period.is_default: if currency.code == default_currency_code() and period.is_default:
return url_for("accounting.report.income-statement-default", return url_for("accounting-report.income-statement-default")
currency=currency) return url_for("accounting-report.income-statement",
return url_for("accounting.report.income-statement",
currency=currency, period=period) currency=currency, period=period)
@ -110,8 +107,19 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
:param period: The period. :param period: The period.
:return: The URL of the balance sheet. :return: The URL of the balance sheet.
""" """
if period.is_default: if currency.code == default_currency_code() and period.is_default:
return url_for("accounting.report.balance-sheet-default", return url_for("accounting-report.balance-sheet-default")
currency=currency) return url_for("accounting-report.balance-sheet",
return url_for("accounting.report.balance-sheet",
currency=currency, period=period) currency=currency, period=period)
def unapplied_url(account: Account | None) -> str:
"""Returns the URL of the unapplied original line items.
:param account: The account, or None to list the accounts with unapplied
original line items.
:return: The URL of the unapplied original line items.
"""
if account is None:
return url_for("accounting-report.unapplied-default")
return url_for("accounting-report.unapplied", account=account)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -23,13 +23,16 @@ from accounting import db
from accounting.models import Currency, Account from accounting.models import Currency, Account
from accounting.report.period import Period, get_period from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search IncomeStatement, BalanceSheet, Search
from .reports.unapplied import UnappliedOriginalLineItems
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
from .template_filters import format_amount from .template_filters import format_amount
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
bp: Blueprint = Blueprint("report", __name__) bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""
bp.add_app_template_filter(format_amount, "accounting_report_format_amount") bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
@ -41,10 +44,7 @@ def get_default_report() -> str | Response:
:return: The income and expenses log in the default period. :return: The income and expenses log in the default period.
""" """
return __get_income_expenses( return get_default_income_expenses()
db.session.get(Currency, default_currency_code()),
default_ie_account(),
get_period())
@bp.get("journal", endpoint="journal-default") @bp.get("journal", endpoint="journal-default")
@ -80,17 +80,15 @@ def __get_journal(period: Period) -> str | Response:
return report.html() return report.html()
@bp.get("ledger/<currency:currency>/<account:account>", @bp.get("ledger", endpoint="ledger-default")
endpoint="ledger-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_ledger(currency: Currency, account: Account) -> str | Response: def get_default_ledger() -> str | Response:
"""Returns the ledger in the default period. """Returns the ledger in the default currency, cash, and default period.
:param currency: The currency. :return: The ledger in the default currency, cash, and default period.
:param account: The account.
:return: The ledger in the default period.
""" """
return __get_ledger(currency, account, get_period()) return __get_ledger(db.session.get(Currency, default_currency_code()),
Account.cash(), get_period())
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>", @bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
@ -123,26 +121,23 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
return report.html() return report.html()
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>", @bp.get("income-expenses", endpoint="income-expenses-default")
endpoint="income-expenses-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_income_expenses(currency: Currency, def get_default_income_expenses() -> str | Response:
account: IncomeExpensesAccount) \
-> str | Response:
"""Returns the income and expenses log in the default period. """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: The income and expenses log in the default period.
""" """
return __get_income_expenses(currency, account, get_period()) return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
options.default_ie_account,
get_period())
@bp.get( @bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>", "<period:period>", endpoint="income-expenses")
endpoint="income-expenses")
@has_permission(can_view) @has_permission(can_view)
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount, def get_income_expenses(currency: Currency, account: CurrentAccount,
period: Period) -> str | Response: period: Period) -> str | Response:
"""Returns the income and expenses log. """Returns the income and expenses log.
@ -154,7 +149,7 @@ def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
return __get_income_expenses(currency, account, period) return __get_income_expenses(currency, account, period)
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount, def __get_income_expenses(currency: Currency, account: CurrentAccount,
period: Period) -> str | Response: period: Period) -> str | Response:
"""Returns the income and expenses log. """Returns the income and expenses log.
@ -169,16 +164,15 @@ def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
return report.html() return report.html()
@bp.get("trial-balance/<currency:currency>", @bp.get("trial-balance", endpoint="trial-balance-default")
endpoint="trial-balance-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_trial_balance(currency: Currency) -> str | Response: def get_default_trial_balance() -> str | Response:
"""Returns the trial balance in the default period. """Returns the trial balance in the default period.
:param currency: The currency.
:return: The trial balance in the default period. :return: The trial balance in the default period.
""" """
return __get_trial_balance(currency, get_period()) return __get_trial_balance(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("trial-balance/<currency:currency>/<period:period>", @bp.get("trial-balance/<currency:currency>/<period:period>",
@ -207,16 +201,15 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
return report.html() return report.html()
@bp.get("income-statement/<currency:currency>", @bp.get("income-statement", endpoint="income-statement-default")
endpoint="income-statement-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_income_statement(currency: Currency) -> str | Response: def get_default_income_statement() -> str | Response:
"""Returns the income statement in the default period. """Returns the income statement in the default period.
:param currency: The currency.
:return: The income statement in the default period. :return: The income statement in the default period.
""" """
return __get_income_statement(currency, get_period()) return __get_income_statement(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("income-statement/<currency:currency>/<period:period>", @bp.get("income-statement/<currency:currency>/<period:period>",
@ -246,16 +239,15 @@ def __get_income_statement(currency: Currency, period: Period) \
return report.html() return report.html()
@bp.get("balance-sheet/<currency:currency>", @bp.get("balance-sheet", endpoint="balance-sheet-default")
endpoint="balance-sheet-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_balance_sheet(currency: Currency) -> str | Response: def get_default_balance_sheet() -> str | Response:
"""Returns the balance sheet in the default period. """Returns the balance sheet in the default period.
:param currency: The currency.
:return: The balance sheet in the default period. :return: The balance sheet in the default period.
""" """
return __get_balance_sheet(currency, get_period()) return __get_balance_sheet(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("balance-sheet/<currency:currency>/<period:period>", @bp.get("balance-sheet/<currency:currency>/<period:period>",
@ -286,6 +278,34 @@ def __get_balance_sheet(currency: Currency, period: Period) \
return report.html() return report.html()
@bp.get("unapplied", endpoint="unapplied-default")
@has_permission(can_view)
def get_default_unapplied() -> str | Response:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
report: AccountsWithUnappliedOriginalLineItems \
= AccountsWithUnappliedOriginalLineItems()
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied")
@has_permission(can_view)
def get_unapplied(account: Account) -> str | Response:
"""Returns the unapplied original line items.
:param account: The Account.
:return: The unapplied original line items.
"""
report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("search", endpoint="search") @bp.get("search", endpoint="search")
@has_permission(can_view) @has_permission(can_view)
def search() -> str | Response: def search() -> str | Response:

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* style.css: The style sheet for the accounting application. * style.css: The style sheet for the accounting application.
*/ */
@ -76,7 +76,7 @@
height: 3.2rem; height: 3.2rem;
width: 3.2rem; width: 3.2rem;
border-radius: 50%; border-radius: 50%;
margin-left: 1rem; margin-right: 0.5rem;
} }
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn { .accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
padding-top: 0.7rem; padding-top: 0.7rem;
@ -117,29 +117,29 @@
} }
/* Links between objects */ /* Links between objects */
.accounting-original-entry { .accounting-original-line-item {
border-top: thin solid darkslategray; border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
} }
.accounting-original-entry a { .accounting-original-line-item a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.accounting-original-entry a:hover { .accounting-original-line-item a:hover {
color: inherit; color: inherit;
} }
.accounting-offset-entries { .accounting-offset-line-items {
border-top: thin solid darkslategray; border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
} }
.accounting-offset-entries ul li { .accounting-offset-line-items ul li {
list-style: none; list-style: none;
} }
.accounting-offset-entries ul li a { .accounting-offset-line-items ul li a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.accounting-offset-entries ul li a:hover { .accounting-offset-line-items ul li a:hover {
color: inherit; color: inherit;
} }
@ -149,38 +149,35 @@
overflow-y: scroll; overflow-y: scroll;
} }
/** The transaction management */ /** The journal entry management */
.accounting-currency-control { .accounting-currency-control {
background-color: transparent; background-color: transparent;
} }
.accounting-currency-content { .accounting-currency-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
} }
.accounting-entry-content { .accounting-line-item-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
background-color: transparent; background-color: transparent;
} }
.accounting-entry-control {
border-color: transparent;
}
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) { .accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
.accounting-list-group-hover .list-group-item:hover { .accounting-list-group-hover .list-group-item:hover {
background-color: #ececec; background-color: #ececec;
} }
.accounting-transaction-entry { .accounting-journal-entry-line-item {
border: none; border: none;
} }
.accounting-transaction-entry-header { .accounting-journal-entry-line-item-header {
font-weight: bolder; font-weight: bolder;
border-bottom: thick double slategray; border-bottom: thick double slategray;
} }
.list-group-item.accounting-transaction-entry-total { .list-group-item.accounting-journal-entry-line-item-total {
font-weight: bolder; font-weight: bolder;
border-top: thick double slategray; border-top: thick double slategray;
} }
.accounting-entry-editor-original-entry-content { .accounting-line-item-editor-original-line-item-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
} }
@ -212,11 +209,23 @@ a.accounting-report-table-row {
.accounting-report-table-body .accounting-amount { .accounting-report-table-body .accounting-amount {
font-style: italic; font-style: italic;
} }
.accounting-report-table-body .accounting-report-table-row {
background-color: #f8f9fa;
}
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) { .accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
background-color: #f2f2f2; background-color: #ecedee;
} }
.accounting-report-table-body .accounting-report-table-row:hover { .accounting-report-table-body .accounting-report-table-row:hover {
background-color: rgba(0, 0, 0, 0.075); background-color: #e5e6e7;
}
.accounting-report-table-body .accounting-report-table-row-danger {
background-color: #f8d7da;
}
.accounting-report-table-body .accounting-report-table-row-danger:nth-child(2n+1) {
background-color: #eccccf;
}
.accounting-report-table-body .accounting-report-table-row-danger:hover {
background-color: #e5c7ca;
} }
.accounting-journal-table .accounting-report-table-row { .accounting-journal-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr; grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
@ -312,12 +321,56 @@ a.accounting-report-table-row {
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount { .accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
font-style: italic; font-style: italic;
} }
.accounting-unapplied-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 5fr 1fr 1fr;
}
.accounting-unapplied-account-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
/* The accounting report */ /* The accounting report */
.accounting-mobile-journal-credit { .accounting-mobile-journal-credit {
padding-left: 1rem; padding-left: 1rem;
} }
/* The description editor */
.accounting-description-editor-buttons {
max-height: 7rem;
overflow-y: scroll;
}
.accounting-description-editor-buttons .btn {
margin-bottom: 0.3rem;
}
/* The order of the journal entries in a same day */
.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
color: inherit;
text-decoration: none;
}
.accounting-journal-entry-order-item-currency {
margin-left: 0.5rem;
border-top: thin solid lightgray;
margin-top: 0.2rem;
}
/* The illustration of the description template for the recurring transactions */
.accounting-recurring-description-template-illustration p {
margin: 0.2rem 0;
}
.accounting-recurring-description-template-illustration ul {
margin: 0;
}
/* The unmatched offsets */
.accounting-unmatched-offset-pair-list {
height: 20rem;
overflow-y: scroll;
}
/* The Material Design text field (floating form control in Bootstrap) */ /* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field { .accounting-material-text-field {
position: relative; position: relative;
@ -342,7 +395,7 @@ a.accounting-report-table-row {
.accounting-material-fab { .accounting-material-fab {
position: fixed; position: fixed;
right: 2rem; right: 2rem;
bottom: 1rem; bottom: 2rem;
z-index: 10; z-index: 10;
flex-direction: column-reverse; flex-direction: column-reverse;
} }

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* account-form.js: The JavaScript for the account form * account-form.js: The JavaScript for the account form
*/ */
@ -110,14 +110,23 @@ class AccountForm {
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control"); this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
this.#isNeedOffset = document.getElementById("accounting-is-need-offset"); this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
this.#formElement.onsubmit = () => { this.#formElement.onsubmit = () => {
return this.#validateForm(); return this.#validate();
}; };
this.#baseControl.onclick = () => { this.#baseControl.onclick = () => {
this.#baseControl.classList.add("accounting-not-empty"); this.#baseControl.classList.add("accounting-not-empty");
this.#baseAccountSelector.onOpen(this.#baseCode.value); this.#baseAccountSelector.onOpen();
}; };
} }
/**
* Returns the base code.
*
* @return {string|null}
*/
get baseCode() {
return this.#baseCode.value === ""? null: this.#baseCode.value;
}
/** /**
* The callback when the base account selector is closed. * The callback when the base account selector is closed.
* *
@ -129,15 +138,14 @@ class AccountForm {
} }
/** /**
* Sets the base account. * Saves the selected base account.
* *
* @param code {string} the base account code * @param account {BaseAccountOption} the selected base account
* @param text {string} the text for the base account
*/ */
setBaseAccount(code, text) { saveBaseAccount(account) {
this.#baseCode.value = code; this.#baseCode.value = account.code;
this.#base.innerText = text; this.#base.innerText = account.text;
if (["1", "2", "3"].includes(code.substring(0, 1))) { if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
this.#isNeedOffsetControl.classList.remove("d-none"); this.#isNeedOffsetControl.classList.remove("d-none");
this.#isNeedOffset.disabled = false; this.#isNeedOffset.disabled = false;
} else { } else {
@ -163,7 +171,7 @@ class AccountForm {
* *
* @returns {boolean} true if valid, or false otherwise * @returns {boolean} true if valid, or false otherwise
*/ */
#validateForm() { #validate() {
let isValid = true; let isValid = true;
isValid = this.#validateBase() && isValid; isValid = this.#validateBase() && isValid;
isValid = this.#validateTitle() && isValid; isValid = this.#validateTitle() && isValid;
@ -225,7 +233,7 @@ class BaseAccountSelector {
* The account form * The account form
* @type {AccountForm} * @type {AccountForm}
*/ */
#form; form;
/** /**
* The selector modal * The selector modal
@ -253,7 +261,7 @@ class BaseAccountSelector {
/** /**
* The options * The options
* @type {HTMLLIElement[]} * @type {BaseAccountOption[]}
*/ */
#options; #options;
@ -269,83 +277,54 @@ class BaseAccountSelector {
* @param form {AccountForm} the form * @param form {AccountForm} the form
*/ */
constructor(form) { constructor(form) {
this.#form = form; this.form = form;
this.#modal = document.getElementById("accounting-base-selector-modal"); const prefix = "accounting-base-selector";
this.#query = document.getElementById("accounting-base-selector-query"); this.#modal = document.getElementById(`${prefix}-modal`);
this.#optionList = document.getElementById("accounting-base-selector-option-list"); this.#query = document.getElementById(`${prefix}-query`);
// noinspection JSValidateTypes this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option")); this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#clearButton = document.getElementById("accounting-base-selector-clear"); this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result"); this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#modal.addEventListener("hidden.bs.modal", () => {
this.#form.onBaseAccountSelectorClosed(); this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
}); this.#query.oninput = () => this.#filterOptions();
for (const option of this.#options) { this.#clearButton.onclick = () => this.form.clearBaseAccount();
option.onclick = () => {
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
};
}
this.#clearButton.onclick = () => {
this.#form.clearBaseAccount();
};
this.#initializeBaseAccountQuery();
} }
/** /**
* Initializes the query. * Filters the options.
* *
*/ */
#initializeBaseAccountQuery() { #filterOptions() {
this.#query.addEventListener("input", () => { let isAnyMatched = false;
if (this.#query.value === "") { for (const option of this.#options) {
for (const option of this.#options) { if (option.isMatched(this.#query.value)) {
option.classList.remove("d-none"); option.setShown(true);
} isAnyMatched = true;
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
for (const option of this.#options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(this.#query.value)) {
isMatched = true;
break;
}
}
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
}
if (!hasAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else { } else {
this.#optionList.classList.remove("d-none"); option.setShown(false);
this.#queryNoResult.classList.add("d-none");
} }
}); }
if (!isAnyMatched) {
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 base account selector is shown. * The callback when the base account selector is shown.
* *
* @param baseCode {string} the active base code
*/ */
onOpen(baseCode) { onOpen() {
this.#query.value = "";
this.#filterOptions();
for (const option of this.#options) { for (const option of this.#options) {
if (option.dataset.code === baseCode) { option.setActive(option.code === this.form.baseCode);
option.classList.add("active");
} else {
option.classList.remove("active");
}
} }
if (baseCode === "") { if (this.form.baseCode === null) {
this.#clearButton.classList.add("btn-secondary") this.#clearButton.classList.add("btn-secondary")
this.#clearButton.classList.remove("btn-danger"); this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true; this.#clearButton.disabled = true;
@ -356,3 +335,93 @@ class BaseAccountSelector {
} }
} }
} }
/**
* A base account option.
*
*/
class BaseAccountOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The account code
* @type {string}
*/
code;
/**
* The account text
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[]}
*/
#queryValues;
/**
* Constructs the account in the base account selector.
*
* @param selector {BaseAccountSelector} the base account selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#element = element;
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => selector.form.saveBaseAccount(this);
}
/**
* Returns whether the account matches the query.
*
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatched(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
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

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* account-order.js: The JavaScript for the account order * account-order.js: The JavaScript for the account order
*/ */
@ -29,10 +29,11 @@ document.addEventListener("DOMContentLoaded", () => {
const onReorder = () => { const onReorder = () => {
const accounts = Array.from(list.children); const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) { for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code"); const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`);
no.value = String(i + 1); no.value = String(i + 1);
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3); const zeroPaddedNo = `000${no.value}`.slice(-3)
code.innerText = `${list.dataset.baseCode}-${zeroPaddedNo}`;
} }
}; };
initializeDragAndDropReordering(list, onReorder); initializeDragAndDropReordering(list, onReorder);

View File

@ -1,224 +0,0 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/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

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* currency-form.js: The JavaScript for the currency form * currency-form.js: The JavaScript for the currency form
*/ */
@ -81,7 +81,7 @@ class CurrencyForm {
this.#validateName(); this.#validateName();
}; };
this.#formElement.onsubmit = () => { this.#formElement.onsubmit = () => {
this.#validateForm().then((isValid) => { this.#validate().then((isValid) => {
if (isValid) { if (isValid) {
this.#formElement.submit(); this.#formElement.submit();
} }
@ -95,7 +95,7 @@ class CurrencyForm {
* *
* @returns {Promise<boolean>} true if valid, or false otherwise * @returns {Promise<boolean>} true if valid, or false otherwise
*/ */
async #validateForm() { async #validate() {
let isValid = true; let isValid = true;
isValid = await this.#validateCode() && isValid; isValid = await this.#validateCode() && isValid;
isValid = this.#validateName() && isValid; isValid = this.#validateName() && isValid;
@ -128,7 +128,7 @@ class CurrencyForm {
} }
const original = this.#code.dataset.original; const original = this.#code.dataset.original;
if (original === "" || this.#code.value !== original) { if (original === "" || this.#code.value !== original) {
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value)); const response = await fetch(`${this.#code.dataset.existsUrl}?q=${encodeURIComponent(this.#code.value)}`);
const data = await response.json(); const data = await response.json();
if (data["exists"]) { if (data["exists"]) {
this.#code.classList.add("is-invalid"); this.#code.classList.add("is-invalid");

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop * drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
*/ */

View File

@ -0,0 +1,312 @@
/* The Mia! Accounting Project
* journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
"use strict";
/**
* The account selector.
*
*/
class JournalEntryAccountSelector {
/**
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
lineItemEditor;
/**
* Either "debit" or "credit"
* @type {string}
*/
#debitCredit;
/**
* 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 {JournalEntryAccountOption[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
*/
#more;
/**
* Whether to show all accounts
* @type {boolean}
*/
#isShowMore = false;
/**
* Constructs an account selector.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor
this.#debitCredit = debitCredit;
const prefix = `accounting-account-selector-${debitCredit}`;
this.#query = document.getElementById(`${prefix}-query`);
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(this, element));
this.#more = document.getElementById(`${prefix}-more`);
this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
this.#more.onclick = () => {
this.#isShowMore = true;
this.#more.classList.add("d-none");
this.#filterOptions();
};
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
}
/**
* Filters the options.
*
*/
#filterOptions() {
const codesInUse = this.#getCodesUsedInForm();
let isAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
option.setShown(true);
isAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!isAnyMatched && this.#isShowMore) {
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.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.lineItemEditor.account !== null) {
inUse.push(this.lineItemEditor.account.code);
}
return inUse
}
/**
* The callback when the account selector is shown.
*
*/
onOpen() {
this.#query.value = "";
this.#isShowMore = false;
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code);
}
if (this.lineItemEditor.account === 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 lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
*/
static getInstances(lineItemEditor) {
const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) {
selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit);
}
return selectors;
}
}
/**
* An account option
*
*/
class JournalEntryAccountOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The account code
* @type {string}
*/
code;
/**
* The account text
* @type {string}
*/
text;
/**
* Whether the account is in use
* @type {boolean}
*/
#isInUse;
/**
* Whether line items in the account need offset
* @type {boolean}
*/
isNeedOffset;
/**
* The values to query against
* @type {string[]}
*/
#queryValues;
/**
* Constructs the account in the account selector.
*
* @param selector {JournalEntryAccountSelector} the account selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#element = element;
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#isInUse = element.classList.contains("accounting-account-is-in-use");
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => selector.lineItemEditor.saveAccount(this);
}
/**
* Returns whether the account matches the query.
*
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
* @param codesInUse {string[]} the account codes that are used in the form
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatched(isShowMore, codesInUse, query) {
return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
}
/**
* Returns whether the account matches the "in-use" condition.
*
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
* @param codesInUse {string[]} the account codes that are used in the form
* @return {boolean} true if the option matches, or false otherwise
*/
#isInUseMatched(isShowMore, codesInUse) {
return isShowMore || this.#isInUse || codesInUse.includes(this.code);
}
/**
* Returns whether the account matches the query term.
*
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
#isQueryMatched(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
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");
}
}
}

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