Compare commits

...

236 Commits
v1.0.1 ... main

Author SHA1 Message Date
f20c462685 Advanced to version 1.6.0. 2024-06-04 08:29:26 +08:00
80ae4bd91c Revised the calculation of "today" to use the client's timezone instead of the server's timezone. 2024-06-04 08:28:59 +08:00
6ee3ee76ea Updated optional dependencies in pyproject.toml. 2024-06-04 08:28:58 +08:00
2bfcc8b889 Updated the dependencies in pyproject.toml. 2024-06-04 08:28:15 +08:00
99564c02d0 Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test site. 2024-04-21 22:41:46 +02:00
25d9904180 Applied the new type parameter syntax to the generic classes for Python 3.12. 2024-03-03 07:39:37 +08:00
1cf83adf87 Applied the "type" statement to type aliases for Python 3.12. 2024-03-03 07:39:20 +08:00
8e3d1f11b5 Updated Python version to 3.12. 2024-03-03 07:38:59 +08:00
0ab14aa34d Updated the copyright year in README.rst. 2024-03-03 07:38:32 +08:00
e0ed81ad1f Advanced to version 1.5.11. 2023-12-16 21:52:15 +08:00
ece7481e9e Refined to enable the selection of the 3351-001 Accumulated Profit or Loss account. 2023-12-16 21:51:14 +08:00
50d4526e0b Advanced to version 1.5.10. 2023-11-28 08:27:31 +08:00
3f0a0b4227 Fixed the form validator to enable the selection of Accumulated Profit or Loss accounts other than 3351-001. 2023-11-28 08:26:37 +08:00
dcc9626b23 Fixed the release date of version 1.5.9 in the change log. 2023-11-28 08:17:25 +08:00
79eb077129 Advanced to version 1.5.9. 2023-11-28 08:10:00 +08:00
d5719ad223 Refined to enable the selection of Accumulated Profit or Loss accounts other than 3351-001, facilitating the consolidation of existing balances. 2023-11-28 08:09:35 +08:00
eb3fa8f414 Added docs/requirements.txt and the "sphinx_rtd_theme" theme to the readthedocs configuration, as Read the Docs does not install sphinx_rtd_theme by default
after August 7, 2023.
2023-11-28 08:04:11 +08:00
937908717b Advanced to version 1.5.8. 2023-10-24 05:00:53 +05:30
0104fa4c21 Fixed an icon in the detail of the cash receipt journal entry. 2023-10-24 04:43:11 +05:30
14365ca255 Advanced to version 1.5.7. 2023-07-29 13:24:52 +08:00
cd86651606 Added the "accounting-titleize" console command to capitalize the existing account titles that were already initialized. 2023-07-29 13:11:47 +08:00
9147744ff7 Renamed the test_init test to test_init_db in the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
1a212a5330 Updated the documentation of the test_init test of the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
0614457b7b Moved dropping tables from the setUp method to the test_init test in the ConsoleCommandTestCase test case. The other tests may not need to drop the tables first. 2023-07-29 13:07:08 +08:00
545f49043b Updated the Sphinx documentation. 2023-07-29 13:07:08 +08:00
cac0d66ca1 Updated the translation. 2023-07-29 13:07:08 +08:00
5ffd37c859 Revised to capitalize the account titles when initializing the base accounts instead of when displaying the account titles, so that the titles of the user-added accounts are not capitalized incorrectly. 2023-07-29 13:06:32 +08:00
9ae8c1bce9 Updated the translation. 2023-07-29 10:11:45 +08:00
ec0ff3e2e6 Updated the log in message at the home page, and removed the next URI from the log in link. The next URI is not clear text but encrypted now. There is no need to attach the next URI, as it defaults redirects to the accounting application without the next URI. 2023-07-29 10:11:45 +08:00
40a8080751 Removed unused imports from the test site. 2023-07-29 10:11:45 +08:00
736a4086ee Removed an unused import from testlib_journal_entry.py. 2023-07-29 10:11:45 +08:00
6723077b72 Revised the code to read from the CSV data files in the __test_base_account_data method of the ConsoleCommandTestCase test case, to prevent PyCharm from complaining. 2023-07-29 10:11:45 +08:00
0ae00bce79 Changed the properties of the test cases from public to private. 2023-07-29 10:11:45 +08:00
356d2010cc Removed the CSRF token from the get_client function in testlib.py, so that type hints and documentation can be added to the client and the CSRF token properties separately. 2023-07-29 10:11:45 +08:00
501c4b1d22 Added missing documentation to the global variables, class properties, and object properties. 2023-07-29 10:11:44 +08:00
64b9c8c11f Removed an excess property declaration in the populate_obj method of the JournalEntryForm form. 2023-07-29 10:03:46 +08:00
9072de82d4 Added the "decode_next" utility in the "accounting.utils.next_uri" module, and applied the "encode_next" and "decode_next" utilities to the NextUriTestCase test case, so that the test case do not need to get involved into the detail of the next URI encryption. 2023-07-29 10:03:45 +08:00
30fd9c2164 Fixed the documentation of the "is_default" property of the Period utility. 2023-06-05 22:43:35 +08:00
7cb01b4cee Revised the documentation of the columns of the data models. 2023-06-05 16:55:25 +08:00
9a4e04c41f Renamed the HTML ID "collapsible-navbar" to "accounting-collapsible-navbar" in the test site. 2023-06-03 11:12:28 +08:00
a9c4fa9de0 Advanced to version 1.5.6. 2023-05-23 09:32:48 +08:00
3a676e0b5a Fixed the back URL of the creation forms, applying the accounting_or_next filter for the decoded next URI instead of getting the next URI directly. 2023-05-23 09:32:48 +08:00
9cc7b64bb3 Moved the "__as_next" utility from the test site to the "accounting.utils.next_uri" module, and applied it to the template of the unmatched offset list. 2023-05-23 09:32:48 +08:00
352867797d Advanced to version 1.5.5. 2023-05-23 09:30:33 +08:00
09a344d749 Removed excess spaces from the test_change_date test of the JournalEntryReorderTestCase test case. 2023-05-23 09:30:33 +08:00
818c357613 Revised the next URI utilities to apply URLSafeSerializer for encoding and decoding the next URI, in order to prevent tampering with the next URI. 2023-05-23 09:30:19 +08:00
822c8fc49b Renamed the "__get_next_uri" function to "__get_next" in the "accounting.utils.next_uri" module. 2023-05-23 07:10:30 +08:00
3b8a2e3bb1 Replaced the "accounting-dummy-form" name with the dummy CSRF token to work with OWASP ZAP CSRF token scans. 2023-05-22 18:32:24 +08:00
9e4927ee0b Replaced the get_errors_view with the get_messages_view in the create_test_app function in testlib.py. 2023-05-22 00:03:13 +08:00
3b030c577c Added the integrity value of the CDN stylesheet links in the base template of the test site. 2023-05-19 18:17:29 +08:00
60b33f2a3b Revised the link to the stylesheet of tempus dominus in the base template of the test site. 2023-05-19 18:17:20 +08:00
08fdf59844 Revised the indent of the flashed success messages in the base template of the test site. 2023-05-19 18:17:11 +08:00
b397515457 Removed the size restriction in the next URI utilities. Buffer overflow may happen with any parameter, not only the "next" parameter. It should be solved in uWSGI, but not the application. 2023-05-18 23:30:36 +08:00
abe90d3483 Advanced to version 1.5.4. 2023-05-18 00:06:16 +08:00
65e7dcdf6d Replaced the "/next" next URI with the NEXT_URI constant in the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-18 00:06:05 +08:00
74e414badf Removed unnecessary f-strings from the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-17 23:54:52 +08:00
69175979ff Added the form name to the dummy forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 22:56:47 +08:00
2f69e0f215 Added the form name to the search forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 21:43:21 +08:00
961385c389 Added SESSION_COOKIE_SAMESITE and SESSION_COOKIE_SECURE to create_app of the test site, to set the SameSite and Secure flags for the session cookie. 2023-05-17 19:57:38 +08:00
a691cfd2da Applied the or_next utility to the set local route of the test site. 2023-05-17 19:57:23 +08:00
482a0faa23 Added safeguard to the next URI utilities from invalid or insecure next URI. 2023-05-17 16:26:35 +08:00
0ecf7b6617 Revised the documentation of the "accounting.utils.cast" module. 2023-05-17 15:33:42 +08:00
4408bbfc82 Updated the JavaScript library versions, and added decimal.js-light to the documentation. 2023-05-06 23:59:06 +08:00
433110f486 Revised the way to query accounts with Flask-SQLAlchemy style queries in the accounts method of the CurrentAccount data model. 2023-05-04 09:35:20 +08:00
0b1dd4f4fc Advanced to version 1.5.3. 2023-04-30 15:07:46 +08:00
46bd27e126 Revised the saveOriginalLineItem method of the JavaScript JournalEntryLineItemEditor class not to override the existing amount when the existing amount is less than the net balance. This make it easier when updating the existing journal entries. 2023-04-30 15:03:59 +08:00
b718d19450 Resolved an issue where, in cases where there was no existing localized title and the default title was submitted, the submitted account title or currency name would be erroneously saved as the localized title. 2023-04-30 15:03:58 +08:00
2969e83afe Advanced to version 1.5.2. 2023-04-30 06:43:18 +08:00
a732656746 Revised the coding style in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:43 +08:00
1daed940b6 Corrected the definition of the "is_offset" property in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:01 +08:00
f29cb00aec Advanced to version 1.5.1. 2023-04-30 05:53:37 +08:00
693f07a49c Removed the "timestamp" and
"user_pk" type aliases for the columns in the data models.  They do not work with the current version of Flask-SQLAlchemy when creating Sphinx documentation.
2023-04-30 05:51:31 +08:00
8c899776f2 Corrected the filename in the csv method of the AccountsWithUnmatchedOffsets report class. 2023-04-30 05:35:13 +08:00
f9aa226bf9 Removed an unnecessary f-string from the csv method of the AccountsWithUnappliedOriginalLineItems report class. 2023-04-30 05:34:34 +08:00
c9bb4197be Fixed the error calling the old "setEnableDescriptionAccount" method in the saveOriginalLineItem method of the JavaScript JournalEntryLineItemEditor class. 2023-04-30 05:27:09 +08:00
9ae8d587d8 Removed the unused "random_pk" annotated type alias. 2023-04-29 04:16:11 +08:00
158058dcfb Updated the documentation of the created_at, created_by, updated_at, updated_by, and visited_at columns of the data models, for consistency. 2023-04-28 21:53:11 +08:00
0bc9947234 Revised the documentation of the new_id function. 2023-04-26 20:36:09 +08:00
8c58a9083a Added type hint subscription for the cls parameter of the new_id function. 2023-04-26 18:31:13 +08:00
f45663754c Fixed the documentation of the "accounting.utils.random_id" module. 2023-04-26 18:30:18 +08:00
cda9e4e3c6 Replaced importing the "typing" module as "t" with importing the individual names in the "typing" module. Since Python 3.9 introduced type hinting generics in standard collections, we do not have as many names to import now. This is also to be consistent with the practices of most major and standard packages and examples. 2023-04-26 18:22:45 +08:00
ee5b447c23 Renamed the "journal_entry_date" variable to "date" in the "__form" method of the JournalEntryData class in the lib module of the test site. 2023-04-26 13:42:47 +08:00
25bfcf4aa4 Fixed the documentation of the balance pseudo property of the JournalEntryLineItem data model. 2023-04-26 13:40:48 +08:00
5956d2cd4c Renamed the "match" parameter to "value" in the setter of the "match" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:48 +08:00
833285d924 Renamed the "is_offset" parameter to "value" in the setter of the "is_offset" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:43 +08:00
dee4f5e83f Renamed the "balance" parameter to "value" in the setter of the "balance" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:37 +08:00
f0d1cae32d Renamed the "net_balance" parameter to "value" in the setter of the "net_balance" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:25 +08:00
5dc71697b3 Renamed the "credit" parameter to "value" in the setter of the "credit" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:20 +08:00
1bb1e03c08 Renamed the "debit" parameter to "value" in the setter of the "debit" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:09 +08:00
914ff92e0f Renamed the "count" parameter to "value" in the setter of the "count" pseudo property of the Account data model, for consistency. 2023-04-26 13:39:56 +08:00
8a1cf463b1 Renamed the "journal_entry_date" parameter to "date" in the constructor of the CSVRow class in the "accounting.report.reports.ledger" module. 2023-04-26 13:34:14 +08:00
d4cf224d6b Renamed the "journal_entry_date" parameter to "date" in the constructor of the CSVRow class in the "accounting.report.reports.income_expenses" module. 2023-04-26 13:33:50 +08:00
8d412ec00a Renamed the "journal_entry_date" parameter to "date" in the show_journal_entry_order route. 2023-04-26 13:32:42 +08:00
2986c518ce Renamed the "journal_entry_date" parameter to "date" in the sort_journal_entries route. 2023-04-26 13:32:30 +08:00
f1351243a6 Renamed the "journal_entry_date" parameter to "date" in the constructor of the JournalEntryReorderForm form. 2023-04-26 13:29:55 +08:00
969e8c76a6 Renamed the "journal_entry_date" parameter to "date" in the "sort_journal_entries_in" function in the "accounting.journal_entry.forms.reorder" module. 2023-04-26 13:29:14 +08:00
10f5e75752 Renamed the "journal_entry_date" variable to "date" in the "test_reorder" test of the JournalEntryReorderTestCase test case. 2023-04-26 13:28:07 +08:00
169b3c292a Renamed the "journal_entry_date" variable to "date" in the "__get_journal_entry_condition" method of the LineItemCollector class in the "accounting.report.reports.search" module. 2023-04-26 13:26:38 +08:00
3eb3aef2f2 Renamed the "j_date" parameter to "date" in the "__next_j_no" method of the BaseTestData class in the lib module of the test site. 2023-04-26 13:24:19 +08:00
6c455a615c Renamed the "j_date" variable to "date" in the "_add_journal_entry" method of the BaseTestData class in the lib module of the test site. 2023-04-26 13:23:53 +08:00
4f3339bf68 Renamed the "j_date" variable to "date" in the "__add_usd_recurring" method of the SampleData class in the reset module of the test site. 2023-04-26 13:23:23 +08:00
b5aa7e923f Renamed the "j_date" variable to "date" in the "_init_data" method of the ReportTestData class in test_report.py. 2023-04-26 13:22:46 +08:00
359c335662 Revised the way to import from the datetime package, to avoid name conflict with the common "date" and "time" names. 2023-04-26 13:17:31 +08:00
c11ae23885 Removed an unused import from the "accounting.utils.cast" module. 2023-04-26 13:15:18 +08:00
e083b11394 Removed the random_pk type alias, because autoincrement=False does not seem to work with it. 2023-04-24 23:29:58 +08:00
167990fc4c Renamed the random_id type alias to random_pk. 2023-04-24 20:22:33 +08:00
d5c1be3d80 Rewrote the data model declaration of the test site with the mapped type hint and the mapped columns in SQLAlchemy 2.0. 2023-04-24 14:02:56 +08:00
f6567794e0 Added the documentation to the authentication blueprint of the test site. 2023-04-24 14:00:32 +08:00
ded85d88f7 Added the timestamp, user_pk, and random_id type aliases to simplify the column definition of the data models. 2023-04-24 03:37:33 +08:00
6d780e9296 Revised the title of the change log. 2023-04-23 22:19:01 +08:00
86637267d3 Advanced to version 1.5.0. 2023-04-23 20:13:34 +08:00
71e97721aa Removed the documentation link from the documentation in intro.rst. It does not make sense for a circular link to itself. 2023-04-23 20:13:33 +08:00
5815608288 Added the VERSION constant to the accounting module for the package version, and revised pyproject.toml and conf.py to read the version from it. 2023-04-23 20:13:10 +08:00
5f75d93c6a Simplified README.rst. 2023-04-23 18:42:54 +08:00
118c4b458e Added the change log. 2023-04-23 18:42:42 +08:00
3f7e4c0dda Rewrote the data model declaration with the mapped type hint and the mapped columns in SQLAlchemy 2.0. Added "SQLAlchemy >= 2" to the dependencies. 2023-04-23 13:21:54 +08:00
eed4c923f6 Removed the "be" cast function to cast data type for the binary expressions. It is to be replaced by the mapped type hints. 2023-04-23 13:21:48 +08:00
09dd5ae541 Revised the long line in the JournalEntryConverter converter. 2023-04-23 09:52:21 +08:00
172a12b134 Fixed the type hint of the "currency_options" function. 2023-04-23 09:44:25 +08:00
f3c558f48a Advanced to version 1.4.1. 2023-04-22 18:22:47 +08:00
988757d30e Revised the JavaScript journal entry line item editor to only override the description with the description of the original line item when there is no existing description. 2023-04-20 00:28:28 +08:00
50cea90d1b Revised the JavaScript journal entry line item editor to allow editing the description for offsets and partially-offset original items. 2023-04-20 00:26:58 +08:00
71dfb6f003 Advanced to version 1.4.0. 2023-04-18 09:33:35 +08:00
be628b4aa1 Updated the Sphinx documentation. 2023-04-18 09:33:00 +08:00
5d444adec4 Updated the translation. 2023-04-18 09:32:38 +08:00
014d67f7b8 Removed the period filter from the unapplied original line items and unmatched offsets. It does not make sense for these two reports. 2023-04-18 09:21:42 +08:00
26b4d4388f Revised the imports in the "accounting.report.reports.unmatched" module. 2023-04-18 09:10:25 +08:00
6e2e92d0fb Removed the redundant currency from the title of the reports when the currency is the default currency. 2023-04-18 08:46:23 +08:00
b1f87cb707 Updated the icon of the unmatched offsets. 2023-04-18 08:20:51 +08:00
928dea8312 Added the account information to the original line item selector of the journal entry form. 2023-04-18 08:15:33 +08:00
b8cec8a2af Revised the account options in the report toolbar to be scrollable. 2023-04-18 08:10:33 +08:00
b6ae946f32 Removed the account code from the journal entry form for mobile screens. 2023-04-18 08:10:23 +08:00
a9acc18a6f Removed the account code from the journal entry detail for mobile screens. 2023-04-18 07:13:10 +08:00
5468010c87 Removed the account code from the account list with unmatched offsets for mobile screens. 2023-04-18 07:10:26 +08:00
b505e380df Removed the account code from the account list with unapplied original line items for mobile screens. 2023-04-18 07:10:15 +08:00
412da170e1 Added the get_net_balances function to the "accounting.report.utils.unapplied" module to replace the __get_net_balances methods of the OffsetMatcher and UnappliedOriginalLineItems classes. 2023-04-18 07:04:00 +08:00
fa237795cf Simplified the query for the unapplied original line items, replacing the offset matcher with its own query. 2023-04-18 01:26:02 +08:00
e2f854b5cc Changed the unmatched offsets from a module to a report, and to show both the unapplied original line items and the unmatched offsets instead of only the unmatched offsets, and added the accumulated balance, in order for ease of use. Removed the match information from the unapplied original line item report. Added the currency and period filters to both the unapplied original line item report and unmatched offset reports. 2023-04-18 01:12:04 +08:00
f8895e3bff Revised the documentation of the "__get_unmatched_offsets" method of the OffsetMatcher class. 2023-04-16 22:52:14 +08:00
84ad065782 Merged the "accounting.utils.unapplied" module into the "accounting.utils.offset_matcher" module as the "__get_unapplied" method of the OffsetMatcher class. It is only used in the offset matcher. 2023-04-16 22:51:46 +08:00
260e3cbe82 Advanced to version 1.3.3. 2023-04-13 09:56:16 +08:00
cd039520b6 Added permission checks to the reset routes in the test site. 2023-04-13 09:54:20 +08:00
05e652aa62 Changed the "_journal_entries" and "_line_items" properties in the BaseTestData class from protected to private, renaming them to "__journal_entries" and "__line_items", respectively. There is no need to access it from the child classes anymore. 2023-04-13 09:28:53 +08:00
5c9bf0638c Removed the "csv_data" pseudo property from BaseTestData. 2023-04-13 09:25:50 +08:00
bbc78433fd Moved the sample data generation from the make-sample.py script to the test site. The sample data is generated at real time. This avoids the problem with pre-recorded sample data that the beginning of the months and weeks changes with the day resetting the sample data. 2023-04-13 09:23:57 +08:00
7bcc2b28b2 Moved the JournalEntryLineItemData, JournalEntryCurrencyData, JournalEntryData, and BaseTestData classes from testlib.py to the ".lib" module in the test site. 2023-04-13 08:30:07 +08:00
c1d9ca284c Changed the new_form and update_form methods of the JournalEntryData class in testlib.py to receive the next URI as the parameter instead of the constant, so that the JournalEntryData class can move to other places. 2023-04-13 08:23:52 +08:00
165e28441a Changed the sample data format from JSON to CSV for the test site live demonstration. 2023-04-12 21:33:34 +08:00
621020b0f0 Advanced to version 1.3.2. 2023-04-12 18:05:13 +08:00
6ad36cfaa3 Updated the translation of the test site. 2023-04-12 18:05:13 +08:00
20b0412091 Added the sample data generation and database reset on the test site for live demonstration. 2023-04-12 18:05:13 +08:00
3ca246d3e0 Revised the strings in babel-utils.py and babel-utils-test-site.py. 2023-04-12 15:04:32 +08:00
85d1b13ccd Added the "populate" method to the BaseTestData class, and changed it so that the tests need to call the "populate" method to populate the data, so that it may return the data with populating the database in the future. 2023-04-12 12:28:34 +08:00
3bada28b8f Revised the BaseTestData class in testlib.py to add journal entries directly to the database instead of through the API, in order to allow the data to be reused, and to speed up the test. 2023-04-12 12:12:11 +08:00
8f2cef8d81 Revised the imports in the accounting.journal_entry.converters module. 2023-04-12 00:41:29 +08:00
e62316c477 Advanced to version 1.3.1. 2023-04-11 22:32:22 +08:00
24ddb0c278 Updated the translation of the test site. 2023-04-11 22:27:31 +08:00
536f3390aa Revised the home page of the test site. 2023-04-11 22:27:31 +08:00
fadd8e73b6 Revised the log in process of the test site to return to the previous page after logging in. 2023-04-11 22:27:11 +08:00
12ccf658bf Revised the documentation in README.rst and intro.rst. 2023-04-11 21:56:49 +08:00
e30d1257e5 Revised the navigation bar so that viewers do not see the menu of the unmatched offsets. 2023-04-11 07:50:10 +08:00
404b902d88 Advanced to version 1.3.0. 2023-04-11 00:08:53 +08:00
a560ff175a Updated the Sphinx documentation. 2023-04-11 00:06:17 +08:00
4be1ead6b5 Added the "accounting-init-db" console command to the database initialization of the test site, for simplicity. 2023-04-10 23:58:08 +08:00
700e4f822a Merged the "init-db" console command to the Flask application initialization in the test site, to simplify the code. 2023-04-10 23:50:16 +08:00
c21ed59dfe Replaced SQLAlchemy 1.x-style bulk_save_objects(objects) with SQLAlchemy 2.x-style execute(insert(model), data). 2023-04-10 23:38:31 +08:00
c4a8326bfc Added the "accounting-init-db" console command to replace the trivial "accounting-init-base", "accounting-init-accounts" and "accounting-init-currencies" console commands. 2023-04-10 23:38:27 +08:00
371c80f668 Removed the unused CurrencyData custom type from the "accounting.currency.commands" module. 2023-04-10 23:12:49 +08:00
40be3fb664 Replaced SQLAlchemy 1.x-style bulk_insert_mappings(model, data) with SQLAlchemy 2.x-style execute(insert(model), data). 2023-04-10 19:56:16 +08:00
1e56403b35 Advanced to version 1.2.1. 2023-04-09 21:04:18 +08:00
650c26036a Fixed the search result to allow full year/month/day specification. 2023-04-09 21:03:18 +08:00
b19a6e5ffe Advanced to version 1.2.0. 2023-04-09 12:16:20 +08:00
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
153 changed files with 7513 additions and 2915 deletions

View File

@ -38,3 +38,4 @@ python:
install: install:
- method: pip - method: pip
path: . path: .
- requirements: docs/requirements.txt

View File

@ -7,7 +7,8 @@ Description
=========== ===========
*Mia! Accounting* is an accounting module for Flask_ applications. *Mia! Accounting* is an accounting module for Flask_ applications.
It implements `double-entry bookkeeping`_, and generates the following It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports: accounting reports:
* Trial balance * Trial balance
@ -17,7 +18,17 @@ accounting reports:
In addition, *Mia! Accounting* tracks offsets for unpaid payables and In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables. receivables.
You may try the `live demonstration`_.
Live Demonstration and Test Site
================================
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation Installation
@ -29,156 +40,26 @@ Install *Mia! Accounting* with ``pip``:
pip install mia-accounting pip install mia-accounting
You may also download the from the `PyPI project page`_ or the You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_. `release page`_ on the `Git repository`_.
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.
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, you may start with the
test site.
Documentation Documentation
============= =============
Refer to the `documentation on Read the Docs`_. Refer to the `documentation on Read the Docs`_.
Change Log
==========
Refer to the `change log`_.
Copyright Copyright
========= =========
Copyright (c) 2023 imacat. Copyright (c) 2023-2024 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.
@ -204,18 +85,10 @@ Authors
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping .. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw .. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _PyPI project page: https://pypi.org/project/mia-accounting .. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases .. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting .. _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/
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io .. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
.. _change log: https://mia-accounting.readthedocs.io/en/latest/changelog.html

1
docs/requirements.txt Normal file
View File

@ -0,0 +1 @@
sphinx_rtd_theme

View File

@ -20,14 +20,6 @@ accounting.journal\_entry.utils.description\_editor module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.journal\_entry.utils.offset\_alias module
----------------------------------------------------
.. automodule:: accounting.journal_entry.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.operators module accounting.journal\_entry.utils.operators module
------------------------------------------------ ------------------------------------------------

View File

@ -60,6 +60,38 @@ 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:
accounting.report.reports.unmatched module
------------------------------------------
.. automodule:: accounting.report.reports.unmatched
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unmatched\_accounts module
----------------------------------------------------
.. automodule:: accounting.report.reports.unmatched_accounts
:members:
:undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

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

View File

@ -18,6 +18,14 @@ Subpackages
Submodules Submodules
---------- ----------
accounting.commands module
--------------------------
.. automodule:: accounting.commands
:members:
:undoc-members:
:show-inheritance:
accounting.forms module accounting.forms module
----------------------- -----------------------

View File

@ -44,6 +44,14 @@ 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.options module accounting.utils.options module
------------------------------- -------------------------------
@ -92,6 +100,14 @@ accounting.utils.strip\_text module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.title\_case module
-----------------------------------
.. automodule:: accounting.utils.title_case
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module accounting.utils.user module
---------------------------- ----------------------------

455
docs/source/changelog.rst Normal file
View File

@ -0,0 +1,455 @@
Change Log
==========
Version 1.6.0
--------------
Released 2024/6/4
* Updated Python version to 3.12.
* Revised the calculation of "today" to use the client's timezone instead of
the server's timezone.
* Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test
site.
Version 1.5.11
--------------
Released 2023/12/26
Bug fix.
* Refined to enable the selection of the 3351-001 Accumulated Profit or Loss
account.
Version 1.5.10
--------------
Released 2023/11/28
Bug fix.
* Fixed the form validator to enable the selection of Accumulated Profit or
Loss accounts other than 3351-001.
Version 1.5.9
-------------
Released 2023/11/28
Bug fix.
* Refined to enable the selection of Accumulated Profit or Loss accounts other
than 3351-001, facilitating the consolidation of existing balances.
Version 1.5.8
-------------
Released 2023/10/24
Bug fix.
* Fixed an icon in the detail of the cash receipt journal entry.
Released at Jaipur, India on vacation.
Version 1.5.7
-------------
Released 2023/7/29
Revised account title capitalization to capitalize account titles
upon initialization of base accounts, rather than when displaying
the accounts. This prevents the system from incorrectly
capitalizing titles of user-added accounts.
For existing installation, run the ``accounting-titleize`` console
command to capitalize the existing account titles that were already
initialized.
Other fixes:
* Added missing documentation to the global variables, class
properties, and object properties.
* Various minor fixes.
Version 1.5.6
-------------
Released 2023/5/23
Bug fixes.
* Fixed the return URI of the creation forms to decode the next URI.
* Fixed the unmatched offset list to use the encoded next URI.
Version 1.5.5
-------------
Released 2023/5/23
Security fixes.
* Revised the next URI utilities to encode and decode the next URI
preventing tampering with the next URI.
* Added the integrity value of the CDN stylesheet links.
* Various fixes.
Version 1.5.4
-------------
Released 2023/5/18
Security fixes.
* Added safeguard to the next URI utilities, to prevent Cross-Site
Scripting (XSS) attacks.
* Applied the safe next URI utilities to the test site.
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
of the test site.
Version 1.5.3
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
* Revised the original line item editor not to override the existing
amount when the existing amount is less or equal to the net
balance.
Version 1.5.2
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
Version 1.5.1
-------------
Released 2023/4/30
* Fixed the error calling the old ``setEnableDescriptionAccount``
method in the ``saveOriginalLineItem`` method of the JavaScript
``JournalEntryLineItemEditor`` class.
Version 1.5.0
-------------
Released 2023/4/23
* Updated to require ``SQLAlchemy >= 2``.
* Added the change log.
* Added the ``VERSION`` constant to the ``accounting`` module for
the package version, and revised ``pyproject.toml`` and ``conf.py``
to read the version from it.
Version 1.4.1
-------------
Released 2023/4/22
* Updated to allow editing the description of the journal entry line
item with offsets or are offsetting to original line items.
* Updated not to override the existing description of a journal entry
line item after choosing the original line item to offset to.
Version 1.4.0
-------------
Released 2023/4/18
* Rewrote the unapplied original line items and unmatched offsets.
* The unapplied original line items and unmatched offsets are both
in the report submodule. They can be filtered with currency and
period now.
* Show the unapplied original line items and unmatched offsets
together, and added the accumulated balance in the unmatched
offset list, for ease of reference.
* Removed the account code from the journal entry detail and journal
entry form for mobile devices.
* Made the account options in the reports to be scrollable.
Version 1.3.3
-------------
Released 2023/4/13
Changed the sample data generation in the test site live demonstration
from pre-recorded data to real-time generation, to avoid the problem
with the start of months and weeks changed with the date of the
import.
Version 1.3.2
-------------
Released 2023/4/12
Added the sample data generation and database reset on the test site
for live demonstration.
Version 1.3.1
-------------
Released 2023/4/11
* Fixed the permission of the navigation menu of the unmatched offsets.
* Revised the test site to be more accessible as the live demonstration.
Version 1.3.0
-------------
Released 2023/4/11
Added the ``accounting-init-db`` console command to replace all the
other console commands to initialize the accounting database. The
test site does not work with previous versions (<1.3.0).
Version 1.2.1
-------------
Released 2023/4/9
Fixed the search result to allow full ``year/month/day``
specification.
Version 1.2.0
-------------
Released 2023/4/9
* Simplified the URL of the default reports.
* Fixed the crash with malformed Chinese translation.
* Fixed the crash when downloading CSV data with non-US-ASCII
filenames.
Version 1.1.0
-------------
Released 2023/4/9
* Added the unapplied original line item list, to track unpaid
payables, unreceived receivables, assets, prepaids, refundable
deposits, etc.
* Added the offset matcher to match unapplied original line items
with unmatched offsets.
Version 1.0.1
-------------
Released 2023/4/6
Documentation fixes.
Version 1.0.0
-------------
Released 2023/4/6
The first formal release in Flask.
Added the documentation.
Version 0.11.1 (Pre-release)
----------------------------
Released 2023/4/5
Removed the zero balances from the trial balance, the income
statement, and the balance sheet.
Version 0.11.0 (Pre-release)
----------------------------
Released 2023/4/5
* Renamed the project from ``mia-accounting-flask`` to
``mia-accounting``.
* Updated the URL of the reports, as the default views of the
accounting application.
* Updated ``README``.
* Various fixes.
Version 0.10.0 (Pre-release)
----------------------------
Released 2023/4/3
* Added the unauthorized method to the ``UserUtilityInterface``
interface to allow fine control to how to handle the case when the
user has not logged in.
* Revised the JavaScript description editor to respect the account
that the user has confirmed or specifically selected.
* Various fixes.
Version 0.9.1 (Pre-release)
---------------------------
Released 2023/3/24
* A distinguishable look in the option detail than the option form.
* A better look in the new journal entry forms when there is no line
item yet.
* Fixed the search in the original entry selector in the journal
entry form to always do a partial match, to fix the problem that
there is no match when typing is not finished yet.
* Fixed the search in the original entry selector to search the net
balance correctly.
* Replaced the ``editor`` and ``editor2`` accounts with the ``admin``
and ``editor`` accounts.
* Various fixes.
Version 0.9.0 (Pre-release)
---------------------------
Released 2023/3/23
Moved the settings from the ``.env`` file to the option table in the
database that can be set and updated on the web interface. Added the
settings page to show and update the settings.
Version 0.8.0 (Pre-release)
---------------------------
Released 2023/3/22
* Added the recurring transactions to the description editor.
* Added prevention to delete database objects that are essential or
referenced by others with foreign keys.
* Various fixes on the visual layout.
Version 0.7.0 (Pre-release)
---------------------------
Released 2023/3/21
* Renamed "transaction" to "journal entry", and "journal entry" to
"journal entry line item".
* Renamed ``summary`` to ``description``.
* Updated tempus-dominus from version 6.2.10 to 6.4.3.
* Fixed titles and capitalization.
* Fixed to search case-insensitively.
* Added favicon to the test site.
* Fixed the navigation menu when there is no matching endpoint.
* Various fixes.
Version 0.6.0 (Pre-release)
---------------------------
Released 2023/3/18
* Added offset tracking to the journal entries in the payable and
receivable accounts.
* Renamed the ``is_offset_needed`` column to ``is_need_offset`` in
the ``Account`` data model.
Version 0.5.0 (Pre-release)
---------------------------
Released 2023/3/10
Added the accounting reports.
Version 0.4.0 (Pre-release)
---------------------------
Released 2023/3/1
Added the transaction summary helper.
Version 0.3.1 (Pre-release)
---------------------------
Released 2023/2/28
* Fixed the error that cannot select any account when adding new
transactions.
* Fixed the database error when adding new transactions.
* Added the button to convert a cash income or cash expense
transaction to a transfer transaction.
Version 0.3.0 (Pre-release)
---------------------------
Released 2023/2/27
Added the transaction management.
Version 0.2.0 (Pre-release)
---------------------------
Released 2023/2/7
* Added the currency management.
* Changed the ``can_edit`` permission to at least require the user to
log in first.
* Changed the type hint of the ``current_user`` pseudo property of
the ``AbstractUserUtils`` class to return ``None`` when the user
has not logged in.
Version 0.1.1 (Pre-release)
---------------------------
Released 2023/2/3
Finalized the account management, with tests and reordering.
Version 0.1.0 (Pre-release)
---------------------------
Released 2023/2/3
Added the account management, and updated the API to initialize the
accounting application.
Version 0.0.0 (Pre-release)
---------------------------
Released 2023/2/3
Initial release with main account list, localization, pagination,
query, permission, Sphinx documentation, and a test case based on a
test demonstration site.

View File

@ -6,6 +6,7 @@ import os
import sys import sys
sys.path.insert(0, os.path.abspath('../../src/')) sys.path.insert(0, os.path.abspath('../../src/'))
import accounting
# -- 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
@ -13,7 +14,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting' project = 'Mia! Accounting'
copyright = '2023, imacat' copyright = '2023, imacat'
author = 'imacat' author = 'imacat'
release = '1.0.1' release = accounting.VERSION
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -14,6 +14,7 @@ Welcome to Mia! Accounting's documentation!
accounting accounting
examples examples
history history
changelog

View File

@ -2,7 +2,8 @@ Introduction
============ ============
*Mia! Accounting* is an accounting module for Flask_ applications. *Mia! Accounting* is an accounting module for Flask_ applications.
It implements `double-entry bookkeeping`_, and generates the following It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports: accounting reports:
* Trial balance * Trial balance
@ -12,7 +13,17 @@ accounting reports:
In addition, *Mia! Accounting* tracks offsets for unpaid payables and In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables. receivables.
You may try the `live demonstration`_.
Live Demonstration and Test Site
--------------------------------
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation Installation
@ -24,7 +35,7 @@ Install *Mia! Accounting* with ``pip``:
pip install mia-accounting pip install mia-accounting
You may also download the from the `PyPI project page`_ or the You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_. `release page`_ on the `Git repository`_.
@ -32,15 +43,16 @@ Prerequisites
------------- -------------
You need a running Flask application with database user login. You need a running Flask application with database user login.
The primary key of the user data model must be integer. The primary key of the user data model must be integer. You also
need at least one user.
The following front-end JavaScript libraries must be loaded. You may The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_. download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above * Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above * FontAwesome_ 6.4.0 or above
* `Decimal.js`_ 6.4.3 or above * `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
* `Tempus-Dominus`_ 6.4.3 or above * `Tempus-Dominus`_ 6.7.7 or above
Configuration Configuration
@ -57,24 +69,13 @@ See an example in :ref:`example-userutils`.
Database Initialization Database Initialization
----------------------- -----------------------
After the configuration, you need to run After the configuration, run the ``accounting-init-db`` console
`flask_sqlalchemy.SQLAlchemy.create_all`_ to create the command to initialize the accounting database. You need to specify
database tables that *Mia! Accounting* uses. the username of a user as the data creator.
*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-db -u username
% flask --app myapp accounting-init-accounts
% flask --app myapp accounting-init-currencies
Navigation Menu Navigation Menu
@ -102,37 +103,18 @@ base template:
Check your Flask application and see how it works. Check your Flask application and see how it works.
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, you may start with the
test site.
Documentation
-------------
Refer to the `documentation on Read the Docs`_.
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping .. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw .. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _PyPI project page: https://pypi.org/project/mia-accounting .. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases .. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting .. _Git repository: https://github.com/imacat/mia-accounting
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network .. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com .. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com .. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js .. _decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
.. _Tempus-Dominus: https://getdatepicker.com .. _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/ .. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -17,10 +17,10 @@
[project] [project]
name = "mia-accounting" name = "mia-accounting"
version = "1.0.1" dynamic = ["version"]
description = "A Flask accounting module." description = "A Flask accounting module."
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.11" requires-python = ">=3.12"
authors = [ authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"}, {name = "imacat", email = "imacat@mail.imacat.idv.tw"},
] ]
@ -33,7 +33,8 @@ classifiers = [
"Topic :: Office/Business :: Financial :: Accounting", "Topic :: Office/Business :: Financial :: Accounting",
] ]
dependencies = [ dependencies = [
"flask", "Flask",
"SQLAlchemy >= 2",
"Flask-SQLAlchemy", "Flask-SQLAlchemy",
"Flask-WTF", "Flask-WTF",
"Flask-Babel >= 3", "Flask-Babel >= 3",
@ -41,14 +42,14 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ devel = [
"unittest",
"httpx", "httpx",
"OpenCC", "OpenCC",
] ]
[project.urls] [project.urls]
"Documentation" = "https://mia-accounting.readthedocs.io" "Documentation" = "https://mia-accounting.readthedocs.io"
"Change Log" = "https://mia-accounting.readthedocs.io/en/latest/changelog.html"
"Repository" = "https://github.com/imacat/mia-accounting" "Repository" = "https://github.com/imacat/mia-accounting"
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues" "Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
"Demonstration" = "https://accounting.imacat.idv.tw" "Demonstration" = "https://accounting.imacat.idv.tw"
@ -57,6 +58,9 @@ test = [
requires = ["setuptools>=42"] requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
version = {attr = "accounting.VERSION"}
[tool.setuptools.exclude-package-data] [tool.setuptools.exclude-package-data]
"*" = [ "*" = [
"babel.cfg", "babel.cfg",

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -24,6 +24,8 @@ from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import UserUtilityInterface from accounting.utils.user import UserUtilityInterface
VERSION: str = "1.6.0"
"""The package version."""
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
data_dir: Path = Path(__file__).parent / "data" data_dir: Path = Path(__file__).parent / "data"
@ -61,6 +63,10 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
bp.add_app_template_global(default_currency_code, bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code") "accounting_default_currency_code")
from .commands import init_db_command, titleize_command
app.cli.add_command(init_db_command)
app.cli.add_command(titleize_command)
from . import locale from . import locale
locale.init_app(app, bp) locale.init_app(app, bp)

View File

@ -19,6 +19,8 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as account_bp from .views import bp as account_bp
bp.register_blueprint(account_bp, url_prefix="/accounts") bp.register_blueprint(account_bp, url_prefix="/accounts")
from .commands import init_accounts_command
app.cli.add_command(init_accounts_command)

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -17,44 +17,21 @@
"""The console commands for the account management. """The console commands for the account management.
""" """
import os
from secrets import randbelow from secrets import randbelow
from typing import Any
import click import click
from flask.cli import with_appcontext import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import has_user, get_user_pk from accounting.utils.user import get_user_pk
AccountData = tuple[int, str, int, str, str, str, bool] type AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number, """The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples.""" English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-accounts")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_accounts_command(username: str) -> None: def init_accounts_command(username: str) -> None:
"""Initializes the accounts.""" """Initializes the accounts."""
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
@ -63,8 +40,6 @@ def init_accounts_command(username: str) -> None:
.filter(db.func.length(BaseAccount.code) == 4)\ .filter(db.func.length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all() .order_by(BaseAccount.code).all()
if len(bases) == 0: if len(bases) == 0:
click.echo("Please initialize the base accounts with "
"\"flask accounting-init-base\" first.")
raise click.Abort raise click.Abort
existing: list[Account] = Account.query.all() existing: list[Account] = Account.query.all()
@ -73,7 +48,6 @@ def init_accounts_command(username: str) -> None:
bases_to_add: list[BaseAccount] = [x for x in bases bases_to_add: list[BaseAccount] = [x for x in bases
if x.code not in existing_base_code] if x.code not in existing_base_code]
if len(bases_to_add) == 0: if len(bases_to_add) == 0:
click.echo("No more account to import.")
return return
existing_id: set[int] = {x.id for x in existing} existing_id: set[int] = {x.id for x in existing}
@ -89,14 +63,24 @@ def init_accounts_command(username: str) -> None:
existing_id.add(new_id) existing_id.add(new_id)
return new_id return new_id
data: list[AccountData] = [] data: list[dict[str, Any]] = []
l10n_data: list[dict[str, Any]] = []
for base in bases_to_add: for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_need_offset: bool = __is_need_offset(base.code) account_id: int = get_new_id()
data.append((get_new_id(), base.code, 1, base.title_l10n, data.append({"id": account_id,
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset)) "base_code": base.code,
__add_accounting_accounts(data, creator_pk) "no": 1,
click.echo(F"{len(data)} added. Accounting accounts initialized.") "title_l10n": base.title_l10n,
"is_need_offset": __is_need_offset(base.code),
"created_by_id": creator_pk,
"updated_by_id": creator_pk})
for locale in {"zh_Hant", "zh_Hans"}:
l10n_data.append({"account_id": account_id,
"locale": locale,
"title": l10n[locale]})
db.session.execute(sa.insert(Account), data)
db.session.execute(sa.insert(AccountL10n), l10n_data)
def __is_need_offset(base_code: str) -> bool: def __is_need_offset(base_code: str) -> bool:
@ -108,42 +92,16 @@ def __is_need_offset(base_code: str) -> bool:
""" """
# 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
return False return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.
:param data: A list of (base code, number, title) tuples.
:param creator_pk: The primary key of the creator.
:return: None.
"""
accounts: list[Account] = [Account(id=x[0],
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_need_offset=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
locale=y[0],
title=y[1])
for x in data
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
db.session.bulk_save_objects(accounts)
db.session.bulk_save_objects(l10n)
db.session.commit()

View File

@ -168,7 +168,9 @@ class AccountReorderForm:
:param base: The base account. :param base: The base account.
""" """
self.base: BaseAccount = base self.base: BaseAccount = base
"""The base account."""
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None: def save_order(self) -> None:
"""Saves the order of the account. """Saves the order of the account.

View File

@ -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.
@ -167,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.
@ -178,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

@ -19,6 +19,8 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_base_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as base_account_bp from .views import bp as base_account_bp
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts") bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
from .commands import init_base_accounts_command
app.cli.add_command(init_base_accounts_command)

View File

@ -19,33 +19,28 @@
""" """
import csv import csv
import click import sqlalchemy as sa
from flask.cli import with_appcontext
from accounting import data_dir from accounting import data_dir
from accounting import db from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.title_case import title_case
@click.command("accounting-init-base")
@with_appcontext
def init_base_accounts_command() -> None: def init_base_accounts_command() -> None:
"""Initializes the base accounts.""" """Initializes the base accounts."""
if BaseAccount.query.first() is not None: if BaseAccount.query.first() is not None:
click.echo("Base accounts already exist.") return
raise click.Abort
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
account_data: list[dict[str, str]] = [{"code": x["code"], account_data: list[dict[str, str]] = [{"code": x["code"],
"title_l10n": x["title"]} "title_l10n": title_case(x["title"])}
for x in data] for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")] locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"], l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
"locale": y, "locale": y,
"title": x[f"l10n-{y}"]} "title": x[f"l10n-{y}"]}
for x in data for y in locales] for x in data for y in locales]
db.session.bulk_insert_mappings(BaseAccount, account_data) db.session.execute(sa.insert(BaseAccount), account_data)
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data) db.session.execute(sa.insert(BaseAccountL10n), l10n_data)
db.session.commit()
click.echo("Base accounts initialized.")

View File

@ -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

@ -0,0 +1,94 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 console commands.
"""
import os
import click
from flask.cli import with_appcontext
from accounting import db
from accounting.account import init_accounts_command
from accounting.base_account import init_base_accounts_command
from accounting.currency import init_currencies_command
from accounting.models import BaseAccount, Account
from accounting.utils.title_case import title_case
from accounting.utils.user import has_user, get_user_pk
import sqlalchemy as sa
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-db")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_db_command(username: str) -> None:
"""Initializes the accounting database."""
db.create_all()
init_base_accounts_command()
init_accounts_command(username)
init_currencies_command(username)
db.session.commit()
click.echo("Accounting database initialized.")
@click.command("accounting-titleize")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def titleize_command(username: str) -> None:
"""Capitalize the account titles."""
updater_pk: int = get_user_pk(username)
updated: int = 0
for base in BaseAccount.query:
new_title: str = title_case(base.title_l10n)
if base.title_l10n != new_title:
base.title_l10n = new_title
updated = updated + 1
for account in Account.query:
if account.title_l10n.lower() == account.base.title_l10n.lower():
new_title: str = title_case(account.title_l10n)
if account.title_l10n != new_title:
account.title_l10n = new_title
account.updated_at = sa.func.now()
account.updated_by_id = updater_pk
updated = updated + 1
if updated == 0:
click.echo("All account titles were already capitalized.")
return
db.session.commit()
click.echo(f"{updated} account titles capitalized.")

View File

@ -19,6 +19,8 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_currencies_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -33,6 +35,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as currency_bp, api_bp as currency_api_bp from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies") bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies") bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -18,42 +18,15 @@
""" """
import csv import csv
import os from typing import Any
import typing as t
import click import sqlalchemy as sa
from flask.cli import with_appcontext
from accounting import db, data_dir from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import has_user, get_user_pk from accounting.utils.user import get_user_pk
CurrencyData = tuple[str, str, str, str]
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None: def init_currencies_command(username: str) -> None:
"""Initializes the currencies.""" """Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()} existing_codes: set[str] = {x.code for x in Currency.query.all()}
@ -63,22 +36,18 @@ def init_currencies_command(username: str) -> None:
to_add: list[dict[str, str]] = [x for x in data to_add: list[dict[str, str]] = [x for x in data
if x["code"] not in existing_codes] if x["code"] not in existing_codes]
if len(to_add) == 0: if len(to_add) == 0:
click.echo("No more currency to add.")
return return
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
currency_data: list[dict[str, t.Any]] = [{"code": x["code"], currency_data: list[dict[str, Any]] = [{"code": x["code"],
"name_l10n": x["name"], "name_l10n": x["name"],
"created_by_id": creator_pk, "created_by_id": creator_pk,
"updated_by_id": creator_pk} "updated_by_id": creator_pk}
for x in to_add] for x in to_add]
locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")] locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"currency_code": x["code"], l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
"locale": y, "locale": y,
"name": x[f"l10n-{y}"]} "name": x[f"l10n-{y}"]}
for x in to_add for y in locales] for x in to_add for y in locales]
db.session.bulk_insert_mappings(Currency, currency_data) db.session.execute(sa.insert(Currency), currency_data)
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data) db.session.execute(sa.insert(CurrencyL10n), l10n_data)
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")

View File

@ -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.
@ -93,7 +93,7 @@ def add_currency() -> redirect:
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.
@ -169,7 +169,7 @@ def delete_currency(currency: Currency) -> redirect:
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.

View File

@ -65,12 +65,12 @@ class IsDebitAccount:
:param message: The error message. :param message: The error message.
""" """
self.__message: str | LazyString = message self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
return return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \ if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(self.__message) raise ValidationError(self.__message)
@ -85,12 +85,12 @@ class IsCreditAccount:
:param message: The error message. :param message: The error message.
""" """
self.__message: str | LazyString = message self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
return return
if re.match(r"^(?:[123489]|7[1234])", field.data) \ if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(self.__message) raise ValidationError(self.__message)

View File

@ -17,13 +17,13 @@
"""The path converters for the journal entry management. """The path converters for the journal entry management.
""" """
from datetime import date import datetime as dt
from flask import abort from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import JournalEntry, JournalEntryLineItem from accounting import db
from accounting.models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType from accounting.utils.journal_entry_types import JournalEntryType
@ -37,13 +37,8 @@ class JournalEntryConverter(BaseConverter):
:param value: The journal entry ID. :param value: The journal entry ID.
:return: The corresponding journal entry. :return: The corresponding journal entry.
""" """
journal_entry: JournalEntry | None = JournalEntry.query\ journal_entry: JournalEntry | None \
.join(JournalEntryLineItem)\ = db.session.get(JournalEntry, value)
.filter(JournalEntry.id == value)\
.options(selectinload(JournalEntry.line_items)
.selectinload(JournalEntryLineItem.offsets)
.selectinload(JournalEntryLineItem.journal_entry))\
.first()
if journal_entry is None: if journal_entry is None:
abort(404) abort(404)
return journal_entry return journal_entry
@ -87,18 +82,18 @@ class DateConverter(BaseConverter):
"""The date converter to convert the ISO date from and to the """The date converter to convert the ISO date from and to the
corresponding date in the routes.""" corresponding date in the routes."""
def to_python(self, value: str) -> date: def to_python(self, value: str) -> dt.date:
"""Converts an ISO date to a date. """Converts an ISO date to a date.
:param value: The ISO date. :param value: The ISO date.
:return: The corresponding date. :return: The corresponding date.
""" """
try: try:
return date.fromisoformat(value) return dt.date.fromisoformat(value)
except ValueError: except ValueError:
abort(404) abort(404)
def to_url(self, value: date) -> str: def to_url(self, value: dt.date) -> str:
"""Converts a date to its ISO date. """Converts a date to its ISO date.
:param value: The date. :param value: The date.

View File

@ -28,14 +28,12 @@ from wtforms.validators import DataRequired
from accounting import db from accounting import db
from accounting.forms import CurrencyExists from accounting.forms import CurrencyExists
from accounting.journal_entry.utils.offset_alias import offset_alias
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem from accounting.models import JournalEntryLineItem
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 .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm 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."""
@ -76,8 +74,8 @@ class KeepCurrencyWhenHavingOffset:
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\ original_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\ = JournalEntryLineItem.query\
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(JournalEntryLineItem.id .filter(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items .in_({x.id.data for x in form.line_items

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -18,8 +18,8 @@
""" """
import datetime as dt import datetime as dt
import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Type
import sqlalchemy as sa import sqlalchemy as sa
from flask_babel import LazyString from flask_babel import LazyString
@ -29,13 +29,13 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db from accounting import db
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \ from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency 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.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
@ -123,7 +123,7 @@ class JournalEntryForm(FlaskForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the journal entry is modified during populate_obj().""" """Whether the journal entry is modified during populate_obj()."""
self.collector: t.Type[LineItemCollector] = LineItemCollector self.collector: Type[LineItemCollector] = LineItemCollector
"""The line item collector. The default is the base abstract """The line item collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" provide their own collectors."""
@ -151,11 +151,10 @@ class JournalEntryForm(FlaskForm):
is_new: bool = obj.id is None is_new: bool = obj.id is None
if is_new: if is_new:
obj.id = new_id(JournalEntry) obj.id = new_id(JournalEntry)
self.date: DateField
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
collector_cls: t.Type[LineItemCollector] = self.collector collector_cls: Type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj) collector: collector_cls = collector_cls(self, obj)
collector.collect() collector.collect()
@ -309,11 +308,7 @@ class JournalEntryForm(FlaskForm):
return db.session.scalar(select) return db.session.scalar(select)
T = t.TypeVar("T", bound=JournalEntryForm) class LineItemCollector[T: JournalEntryForm](ABC):
"""A journal entry form variant."""
class LineItemCollector(t.Generic[T], ABC):
"""The line item collector.""" """The line item collector."""
def __init__(self, form: T, obj: JournalEntry): def __init__(self, form: T, obj: JournalEntry):

View File

@ -17,7 +17,7 @@
"""The line item sub-forms for the journal entry management. """The line item sub-forms for the journal entry management.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -31,9 +31,8 @@ from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \ from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount IsCreditAccount
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntryLineItem from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
@ -127,10 +126,8 @@ class KeepAccountWhenHavingOffset:
assert isinstance(form, LineItemForm) assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None: if field.data is None or form.id.data is None:
return return
line_item: JournalEntryLineItem | None = db.session\ line_item: JournalEntryLineItem | None \
.query(JournalEntryLineItem)\ = db.session.get(JournalEntryLineItem, form.id.data)
.filter(JournalEntryLineItem.id == form.id.data)\
.options(selectinload(JournalEntryLineItem.offsets)).first()
if line_item is None or len(line_item.offsets) == 0: if line_item is None or len(line_item.offsets) == 0:
return return
if field.data != line_item.account_code: if field.data != line_item.account_code:
@ -200,13 +197,13 @@ class NotExceedingOriginalLineItemNetBalance:
existing_line_item_id \ existing_line_item_id \
= {x.id for x in form.journal_entry_form.obj.line_items} = {x.id for x in form.journal_entry_form.obj.line_items}
offset_total_func: sa.Function = sa.func.sum(sa.case( offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntryLineItem.is_debit == is_debit), (JournalEntryLineItem.is_debit == is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar( offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func) sa.select(offset_total_func)
.filter(be(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id
== original_line_item.id), == original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id))) JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None: if offset_total_but_form is None:
offset_total_but_form = Decimal("0") offset_total_but_form = Decimal("0")
@ -234,8 +231,7 @@ class NotLessThanOffsetTotal:
(JournalEntryLineItem.is_debit != is_debit, (JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\ else_=-JournalEntryLineItem.amount)))\
.filter(be(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id == form.id.data)
== form.id.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total) offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total: if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@ -269,6 +265,19 @@ class LineItemForm(FlaskForm):
self.journal_entry_form: JournalEntryForm | None = None self.journal_entry_form: JournalEntryForm | None = None
"""The source journal entry form.""" """The source journal entry form."""
@property
def account_title(self) -> str:
"""Returns the title of the account.
:return: The title 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 account.title
@property @property
def account_text(self) -> str: def account_text(self) -> str:
"""Returns the text representation of the account. """Returns the text representation of the account.
@ -298,7 +307,7 @@ class LineItemForm(FlaskForm):
return getattr(self, "____original_line_item") return getattr(self, "____original_line_item")
@property @property
def original_line_item_date(self) -> date | None: def original_line_item_date(self) -> dt.date | None:
"""Returns the text representation of the original line item. """Returns the text representation of the original line item.
:return: The text representation of the original line item. :return: The text representation of the original line item.
@ -344,14 +353,13 @@ class LineItemForm(FlaskForm):
def get_offsets() -> list[JournalEntryLineItem]: def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None: if not self.is_need_offset or self.id.data is None:
return [] return []
return JournalEntryLineItem.query\ return JournalEntryLineItem.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\ == self.id.data)\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.journal_entry), .options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account), selectinload(JournalEntryLineItem.account)).all()
selectinload(JournalEntryLineItem.offsets)
.selectinload(
JournalEntryLineItem.journal_entry)).all()
setattr(self, "__offsets", get_offsets()) setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets") return getattr(self, "__offsets")

View File

@ -17,7 +17,7 @@
"""The reorder forms for the journal entry management. """The reorder forms for the journal entry management.
""" """
from datetime import date import datetime as dt
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
@ -26,17 +26,15 @@ from accounting import db
from accounting.models import JournalEntry from accounting.models import JournalEntry
def sort_journal_entries_in(journal_entry_date: date, def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
exclude: int | None = None) -> None:
"""Sorts the journal entries under a date after changing the date or """Sorts the journal entries under a date after changing the date or
deleting a journal entry. deleting a journal entry.
:param journal_entry_date: The date of the journal entry. :param date: The date of the journal entry.
:param exclude: The journal entry ID to exclude. :param exclude: The journal entry ID to exclude.
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
= [JournalEntry.date == journal_entry_date]
if exclude is not None: if exclude is not None:
conditions.append(JournalEntry.id != exclude) conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\ journal_entries: list[JournalEntry] = JournalEntry.query\
@ -50,13 +48,15 @@ def sort_journal_entries_in(journal_entry_date: date,
class JournalEntryReorderForm: class JournalEntryReorderForm:
"""The form to reorder the journal entries.""" """The form to reorder the journal entries."""
def __init__(self, journal_entry_date: date): def __init__(self, date: dt.date):
"""Constructs the form to reorder the journal entries in a day. """Constructs the form to reorder the journal entries in a day.
:param journal_entry_date: The date. :param date: The date.
""" """
self.date: date = journal_entry_date self.date: dt.date = date
"""The date."""
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None: def save_order(self) -> None:
"""Saves the order of the account. """Saves the order of the account.

View File

@ -32,6 +32,8 @@ class AccountOption:
"""The account ID.""" """The account ID."""
self.code: str = account.code self.code: str = account.code
"""The account code.""" """The account code."""
self.title: str = account.title
"""The account title."""
self.query_values: list[str] = account.query_values self.query_values: list[str] = account.query_values
"""The values to be queried.""" """The values to be queried."""
self.__str: str = str(account) self.__str: str = str(account)

View File

@ -18,7 +18,7 @@
""" """
import re import re
import typing as t from typing import Literal
import sqlalchemy as sa import sqlalchemy as sa
@ -54,6 +54,14 @@ class DescriptionAccount:
""" """
return str(self.__account) return str(self.__account)
@property
def title(self) -> str:
"""Returns the account title.
:return: The account title.
"""
return self.__account.title
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.
@ -116,12 +124,12 @@ class DescriptionTag:
class DescriptionType: class DescriptionType:
"""A description type""" """A description type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]): def __init__(self, type_id: Literal["general", "travel", "bus"]):
"""Constructs a description 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: Literal["general", "travel", "bus"] = type_id
"""The type ID.""" """The type ID."""
self.__tag_dict: dict[str, DescriptionTag] = {} 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."""
@ -158,8 +166,11 @@ class DescriptionRecurring:
:param account: The account. :param account: The account.
""" """
self.name: str = name self.name: str = name
"""The name."""
self.account: DescriptionAccount = DescriptionAccount(account, 0) self.account: DescriptionAccount = DescriptionAccount(account, 0)
"""The account."""
self.description_template: str = description_template self.description_template: str = description_template
"""The description template."""
@property @property
def account_codes(self) -> list[str]: def account_codes(self) -> list[str]:
@ -173,12 +184,12 @@ class DescriptionRecurring:
class DescriptionDebitCredit: class DescriptionDebitCredit:
"""The description on debit or credit.""" """The description on debit or credit."""
def __init__(self, debit_credit: t.Literal["debit", "credit"]): def __init__(self, debit_credit: Literal["debit", "credit"]):
"""Constructs the description on debit or credit. """Constructs the description on debit or credit.
:param debit_credit: Either "debit" or "credit". :param debit_credit: Either "debit" or "credit".
""" """
self.debit_credit: t.Literal["debit", "credit"] = debit_credit self.debit_credit: Literal["debit", "credit"] = debit_credit
"""Either debit or credit.""" """Either debit or credit."""
self.general: DescriptionType = DescriptionType("general") self.general: DescriptionType = DescriptionType("general")
"""The general tags.""" """The general tags."""
@ -186,14 +197,14 @@ class DescriptionDebitCredit:
"""The travel tags.""" """The travel tags."""
self.bus: DescriptionType = DescriptionType("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[Literal["general", "travel", "bus"],
DescriptionType] \ 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] = [] self.recurring: list[DescriptionRecurring] = []
"""The recurring transactions.""" """The recurring transactions."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"], def add_tag(self, tag_type: Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None: name: str, account: Account, freq: int) -> None:
"""Adds a tag. """Adds a tag.
@ -270,7 +281,7 @@ class DescriptionEditor:
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()}
debit_credit_dict: dict[t.Literal["debit", "credit"], debit_credit_dict: dict[Literal["debit", "credit"],
DescriptionDebitCredit] \ DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}} = {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result: for row in result:

View File

@ -17,19 +17,19 @@
"""The operators for different journal entry types. """The operators for different journal entry types.
""" """
import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Type
from flask import render_template, request, abort from flask import render_template, request, abort
from flask_wtf import FlaskForm 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, \ from accounting.journal_entry.forms import JournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \ CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm TransferJournalEntryForm
from accounting.journal_entry.forms.line_item import LineItemForm from accounting.journal_entry.forms.line_item import LineItemForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
class JournalEntryOperator(ABC): class JournalEntryOperator(ABC):
@ -39,7 +39,7 @@ class JournalEntryOperator(ABC):
@property @property
@abstractmethod @abstractmethod
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@ -100,7 +100,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@ -170,7 +170,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@ -243,7 +243,7 @@ class TransferJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.

View File

@ -24,8 +24,7 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.cast import be from accounting.utils.offset_alias import offset_alias
from .offset_alias import offset_alias
def get_selectable_original_line_items( def get_selectable_original_line_items(
@ -45,8 +44,7 @@ def get_selectable_original_line_items(
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case( net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
(offset.c.id.in_(line_item_id_on_form), 0), (offset.c.id.in_(line_item_id_on_form), 0),
(be(offset.c.is_debit == JournalEntryLineItem.is_debit), (offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset] conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = [] sub_conditions: list[sa.BinaryExpression] = []
@ -60,8 +58,8 @@ def get_selectable_original_line_items(
select_net_balances: sa.Select \ select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance)\ = sa.select(JournalEntryLineItem.id, net_balance)\
.join(Account)\ .join(Account)\
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(JournalEntryLineItem.id)\ .group_by(JournalEntryLineItem.id)\

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -17,7 +17,7 @@
"""The views for the journal entry management. """The views for the journal entry management.
""" """
from datetime import date import datetime as dt
from urllib.parse import parse_qsl, urlencode from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa import sqlalchemy as sa
@ -30,9 +30,10 @@ from accounting.locale import lazy_gettext
from accounting.models import JournalEntry from accounting.models import JournalEntry
from accounting.utils.cast import s from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.journal_entry_types import JournalEntryType from accounting.utils.timezone import get_tz_today
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \ from .template_filters import with_type, to_transfer, format_amount_input, \
@ -49,7 +50,7 @@ bp.add_app_template_filter(format_amount_input,
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html") bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create") @bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
@has_permission(can_edit) @has_permission(can_edit)
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str: def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
"""Shows the form to add a journal entry. """Shows the form to add a journal entry.
@ -67,11 +68,11 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
form.validate() form.validate()
else: else:
form = journal_entry_op.form() form = journal_entry_op.form()
form.date.data = date.today() form.date.data = get_tz_today()
return journal_entry_op.render_create_template(form) return journal_entry_op.render_create_template(form)
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store") @bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect: def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
"""Adds a journal entry. """Adds a journal entry.
@ -98,7 +99,7 @@ def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
return redirect(inherit_next(__get_detail_uri(journal_entry))) return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.get("/<journalEntry:journal_entry>", endpoint="detail") @bp.get("<journalEntry:journal_entry>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_journal_entry_detail(journal_entry: JournalEntry) -> str: def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
"""Shows the journal entry detail. """Shows the journal entry detail.
@ -111,7 +112,7 @@ def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
return journal_entry_op.render_detail_template(journal_entry) return journal_entry_op.render_detail_template(journal_entry)
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit") @bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
@has_permission(can_edit) @has_permission(can_edit)
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str: def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
"""Shows the form to edit a journal entry. """Shows the form to edit a journal entry.
@ -133,7 +134,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
return journal_entry_op.render_edit_template(journal_entry, form) return journal_entry_op.render_edit_template(journal_entry, form)
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update") @bp.post("<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect: def update_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Updates a journal entry. """Updates a journal entry.
@ -166,7 +167,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
return redirect(inherit_next(__get_detail_uri(journal_entry))) return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete") @bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect: def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Deletes a journal entry. """Deletes a journal entry.
@ -186,31 +187,31 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
return redirect(or_next(__get_default_page_uri())) return redirect(or_next(__get_default_page_uri()))
@bp.get("/dates/<date:journal_entry_date>", endpoint="order") @bp.get("dates/<date:date>", endpoint="order")
@has_permission(can_view) @has_permission(can_view)
def show_journal_entry_order(journal_entry_date: date) -> str: def show_journal_entry_order(date: dt.date) -> str:
"""Shows the order of the journal entries in a same date. """Shows the order of the journal entries in a same date.
:param journal_entry_date: The date. :param date: The date.
:return: The order of the journal entries in the date. :return: The order of the journal entries in the date.
""" """
journal_entries: list[JournalEntry] = JournalEntry.query \ journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == journal_entry_date) \ .filter(JournalEntry.date == date) \
.order_by(JournalEntry.no).all() .order_by(JournalEntry.no).all()
return render_template("accounting/journal-entry/order.html", return render_template("accounting/journal-entry/order.html",
date=journal_entry_date, list=journal_entries) date=date, list=journal_entries)
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort") @bp.post("dates/<date:date>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_journal_entries(journal_entry_date: date) -> redirect: def sort_journal_entries(date: dt.date) -> redirect:
"""Reorders the journal entries in a date. """Reorders the journal entries in a date.
:param journal_entry_date: The date. :param date: The date.
:return: The redirection to the incoming account or the account list. The :return: The redirection to the incoming account or the account list. The
reordering operation does not fail. reordering operation does not fail.
""" """
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date) form: JournalEntryReorderForm = JournalEntryReorderForm(date)
form.save_order() form.save_order()
if not form.is_modified: if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success") flash(s(lazy_gettext("The order was not modified.")), "success")

View File

@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
from flask_babel_js import JAVASCRIPT, c2js from flask_babel_js import JAVASCRIPT, c2js
translation_dir: Path = Path(__file__).parent / "translations" translation_dir: Path = Path(__file__).parent / "translations"
"""The directory of the translation files."""
domain: Domain = Domain(translation_directories=[translation_dir], domain: Domain = Domain(translation_directories=[translation_dir],
domain="accounting") domain="accounting")
"""The message domain."""
def gettext(string, **variables) -> str: def gettext(string, **variables) -> str:
@ -120,6 +122,5 @@ 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.
""" """
bp.add_url_rule("/_jstrans.js", "babel_catalog", bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
__babel_js_catalog_view)
app.jinja_env.globals["A_"] = domain.gettext app.jinja_env.globals["A_"] = domain.gettext

View File

@ -19,14 +19,16 @@
""" """
from __future__ import annotations from __future__ import annotations
import datetime as dt
import re import re
import typing as t
from decimal import Decimal from decimal import Decimal
from typing import Type, Self
import sqlalchemy as sa import sqlalchemy as sa
from babel import Locale from babel import Locale
from flask_babel import get_locale, get_babel from flask_babel import get_locale, get_babel
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Mapped, mapped_column
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
@ -37,14 +39,14 @@ class BaseAccount(db.Model):
"""A base account.""" """A base account."""
__tablename__ = "accounting_base_accounts" __tablename__ = "accounting_base_accounts"
"""The table name.""" """The table name."""
code = db.Column(db.String, nullable=False, primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The account code."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
l10n = db.relationship("BaseAccountL10n", back_populates="account", l10n: Mapped[list[BaseAccountL10n]] \
lazy=False) = db.relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
accounts = db.relationship("Account", back_populates="base") accounts: Mapped[list[Account]] = db.relationship(back_populates="base")
"""The descendant accounts under the base account.""" """The descendant accounts under the base account."""
def __str__(self) -> str: def __str__(self) -> str:
@ -52,7 +54,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.title()}" return f"{self.code} {self.title}"
@property @property
def title(self) -> str: def title(self) -> str:
@ -81,17 +83,16 @@ class BaseAccountL10n(db.Model):
"""A localized base account title.""" """A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n" __tablename__ = "accounting_base_accounts_l10n"
"""The table name.""" """The table name."""
account_code = db.Column(db.String, account_code: Mapped[str] \
db.ForeignKey(BaseAccount.code, = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
onupdate="CASCADE", ondelete="CASCADE"),
ondelete="CASCADE"), primary_key=True)
nullable=False, primary_key=True) """The account code."""
"""The code of the account.""" account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
account = db.relationship(BaseAccount, back_populates="l10n")
"""The account.""" """The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
title = db.Column(db.String, nullable=False) title: Mapped[str]
"""The localized title.""" """The localized title."""
@ -99,47 +100,43 @@ class Account(db.Model):
"""An account.""" """An account."""
__tablename__ = "accounting_accounts" __tablename__ = "accounting_accounts"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The account ID.""" """The account ID."""
base_code = db.Column(db.String, base_code: Mapped[str] \
db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"))
nullable=False)
"""The code of the base account.""" """The code of the base account."""
base = db.relationship(BaseAccount, back_populates="accounts") base: Mapped[BaseAccount] = db.relationship(back_populates="accounts")
"""The base account.""" """The base account."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the base account.""" """The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False) is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items 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: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The ID of the updator.""" """The last user who updated the record."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) l10n: Mapped[list[AccountL10n]] \
"""The updator.""" = db.relationship(back_populates="account", lazy=False)
l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False)
"""The localized titles.""" """The localized titles."""
line_items = db.relationship("JournalEntryLineItem", line_items: Mapped[list[JournalEntryLineItem]] \
back_populates="account") = db.relationship(back_populates="account")
"""The journal entry line items.""" """The journal entry line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
@ -154,7 +151,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.title()}" return f"{self.base_code}-{self.no:03d} {self.title}"
@property @property
def code(self) -> str: def code(self) -> str:
@ -185,6 +182,8 @@ class Account(db.Model):
:param value: The new title. :param value: The new title.
:return: None. :return: None.
""" """
if self.title == value:
return
if self.title_l10n is None: if self.title_l10n is None:
self.title_l10n = value self.title_l10n = value
return return
@ -214,6 +213,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, value: int) -> None:
"""Sets the number of items in the account.
:param value: The number of items in the account.
:return: None.
"""
setattr(self, "__count", value)
@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.
@ -251,11 +269,11 @@ class Account(db.Model):
:return: None. :return: None.
""" """
AccountL10n.query.filter(AccountL10n.account == self).delete() AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__ cls: Type[Self] = self.__class__
cls.query.filter(cls.id == self.id).delete() cls.query.filter(cls.id == self.id).delete()
@classmethod @classmethod
def find_by_code(cls, code: str) -> t.Self | None: def find_by_code(cls, code: str) -> Self | None:
"""Finds an account by its code. """Finds an account by its code.
:param code: The code. :param code: The code.
@ -268,7 +286,7 @@ class Account(db.Model):
cls.no == int(m.group(2))).first() cls.no == int(m.group(2))).first()
@classmethod @classmethod
def selectable_debit(cls) -> list[t.Self]: def selectable_debit(cls) -> list[Self]:
"""Returns the selectable debit accounts. """Returns the selectable debit accounts.
Payable line items can not start from debit. Payable line items can not start from debit.
@ -286,12 +304,11 @@ class Account(db.Model):
cls.base_code.startswith("78"), cls.base_code.startswith("78"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3351",
cls.base_code != "3353")\ cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@classmethod @classmethod
def selectable_credit(cls) -> list[t.Self]: def selectable_credit(cls) -> list[Self]:
"""Returns the selectable debit accounts. """Returns the selectable debit accounts.
Receivable line items can not start from credit. Receivable line items can not start from credit.
@ -308,12 +325,11 @@ class Account(db.Model):
cls.base_code.startswith("74"), cls.base_code.startswith("74"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3351",
cls.base_code != "3353")\ cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@classmethod @classmethod
def cash(cls) -> t.Self: def cash(cls) -> Self:
"""Returns the cash account. """Returns the cash account.
:return: The cash account :return: The cash account
@ -321,7 +337,7 @@ class Account(db.Model):
return cls.find_by_code(cls.CASH_CODE) return cls.find_by_code(cls.CASH_CODE)
@classmethod @classmethod
def accumulated_change(cls) -> t.Self: def accumulated_change(cls) -> Self:
"""Returns the accumulated-change account. """Returns the accumulated-change account.
:return: The accumulated-change account :return: The accumulated-change account
@ -333,16 +349,16 @@ class AccountL10n(db.Model):
"""A localized account title.""" """A localized account title."""
__tablename__ = "accounting_accounts_l10n" __tablename__ = "accounting_accounts_l10n"
"""The table name.""" """The table name."""
account_id = db.Column(db.Integer, account_id: Mapped[int] \
db.ForeignKey(Account.id, onupdate="CASCADE", = mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) primary_key=True)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="l10n") account: Mapped[Account] = db.relationship(back_populates="l10n")
"""The account.""" """The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
title = db.Column(db.String, nullable=False) title: Mapped[str]
"""The localized title.""" """The localized title."""
@ -350,35 +366,34 @@ class Currency(db.Model):
"""A currency.""" """A currency."""
__tablename__ = "accounting_currencies" __tablename__ = "accounting_currencies"
"""The table name.""" """The table name."""
code = db.Column(db.String, nullable=False, primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The currency code."""
name_l10n = db.Column("name", db.String, nullable=False) name_l10n: Mapped[str] = mapped_column("name")
"""The name.""" """The currency name."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] \
"""The ID of the updator.""" = db.relationship(foreign_keys=updated_by_id)
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) """The last user who updated the record."""
"""The updator.""" l10n: Mapped[list[CurrencyL10n]] \
l10n = db.relationship("CurrencyL10n", back_populates="currency", = db.relationship(back_populates="currency", lazy=False)
lazy=False)
"""The localized names.""" """The localized names."""
line_items = db.relationship("JournalEntryLineItem", line_items: Mapped[list[JournalEntryLineItem]] \
back_populates="currency") = db.relationship(back_populates="currency")
"""The journal entry line items.""" """The journal entry line items."""
def __str__(self) -> str: def __str__(self) -> str:
@ -409,6 +424,8 @@ class Currency(db.Model):
:param value: The new name. :param value: The new name.
:return: None. :return: None.
""" """
if self.name == value:
return
if self.name_l10n is None: if self.name_l10n is None:
self.name_l10n = value self.name_l10n = value
return return
@ -452,7 +469,7 @@ class Currency(db.Model):
:return: None. :return: None.
""" """
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete() CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
cls: t.Type[t.Self] = self.__class__ cls: Type[Self] = self.__class__
cls.query.filter(cls.code == self.code).delete() cls.query.filter(cls.code == self.code).delete()
@ -460,16 +477,16 @@ class CurrencyL10n(db.Model):
"""A localized currency name.""" """A localized currency name."""
__tablename__ = "accounting_currencies_l10n" __tablename__ = "accounting_currencies_l10n"
"""The table name.""" """The table name."""
currency_code = db.Column(db.String, currency_code: Mapped[str] \
db.ForeignKey(Currency.code, onupdate="CASCADE", = mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) primary_key=True)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="l10n") currency: Mapped[Currency] = db.relationship(back_populates="l10n")
"""The currency.""" """The currency."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
name = db.Column(db.String, nullable=False) name: Mapped[str]
"""The localized name.""" """The localized name."""
@ -520,37 +537,34 @@ class JournalEntry(db.Model):
"""A journal entry.""" """A journal entry."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_journal_entries"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The journal entry ID.""" """The journal entry ID."""
date = db.Column(db.Date, nullable=False) date: Mapped[dt.date]
"""The date.""" """The date."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the date.""" """The journal entry number under the date."""
note = db.Column(db.String) note: Mapped[str | None]
"""The note.""" """The note."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The ID of the updator.""" """The last user who updated the record."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) line_items: Mapped[list[JournalEntryLineItem]] \
"""The updator.""" = db.relationship(back_populates="journal_entry")
line_items = db.relationship("JournalEntryLineItem",
back_populates="journal_entry")
"""The line items.""" """The line items."""
def __str__(self) -> str: def __str__(self) -> str:
@ -640,48 +654,39 @@ class JournalEntryLineItem(db.Model):
"""A line item in the journal entry.""" """A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items" __tablename__ = "accounting_journal_entry_line_items"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The line item ID.""" """The line item ID."""
journal_entry_id = db.Column(db.Integer, journal_entry_id: Mapped[int] \
db.ForeignKey(JournalEntry.id, = mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE",
onupdate="CASCADE", ondelete="CASCADE"))
ondelete="CASCADE"),
nullable=False)
"""The journal entry ID.""" """The journal entry ID."""
journal_entry = db.relationship(JournalEntry, back_populates="line_items") journal_entry: Mapped[JournalEntry] \
= db.relationship(back_populates="line_items")
"""The journal entry.""" """The journal entry."""
is_debit = db.Column(db.Boolean, nullable=False) is_debit: Mapped[bool]
"""True for a debit line item, or False for a credit line item.""" """True for a debit line item, or False for a credit line item."""
no = db.Column(db.Integer, nullable=False) no: Mapped[int]
"""The line item number under the journal entry and debit or credit.""" """The line item number under the journal entry and debit or credit."""
original_line_item_id = db.Column(db.Integer, original_line_item_id: Mapped[int | None] \
db.ForeignKey(id, onupdate="CASCADE"), = mapped_column(db.ForeignKey(id, onupdate="CASCADE"))
nullable=True)
"""The ID of the original line item.""" """The ID of the original line item."""
original_line_item = db.relationship("JournalEntryLineItem", original_line_item: Mapped[JournalEntryLineItem | None] \
back_populates="offsets", = db.relationship(remote_side=id, passive_deletes=True)
remote_side=id, passive_deletes=True)
"""The original line item.""" """The original line item."""
offsets = db.relationship("JournalEntryLineItem", currency_code: Mapped[str] \
back_populates="original_line_item") = mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE"))
"""The offset items."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="line_items") currency: Mapped[Currency] = db.relationship(back_populates="line_items")
"""The currency.""" """The currency."""
account_id = db.Column(db.Integer, account_id: Mapped[int] \
db.ForeignKey(Account.id, = mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE"))
onupdate="CASCADE"),
nullable=False)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="line_items", lazy=False) account: Mapped[Account] \
= db.relationship(back_populates="line_items", lazy=False)
"""The account.""" """The account."""
description = db.Column(db.String, nullable=True) description: Mapped[str | None]
"""The description.""" """The description."""
amount = db.Column(db.Numeric(14, 2), nullable=False) amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2))
"""The amount.""" """The amount."""
def __str__(self) -> str: def __str__(self) -> str:
@ -707,14 +712,6 @@ class JournalEntryLineItem(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 line item.
"""
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 line item needs offset. """Returns whether the line item needs offset.
@ -729,13 +726,43 @@ class JournalEntryLineItem(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.
"""
if not hasattr(self, "__debit"):
setattr(self, "__debit", self.amount if self.is_debit else None)
return getattr(self, "__debit")
@debit.setter
def debit(self, value: Decimal | None) -> None:
"""Sets the debit amount.
:param value: The debit amount.
:return: None.
"""
setattr(self, "__debit", value)
@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 line item. :return: The credit amount, or None if this is not a credit line item.
""" """
return None if self.is_debit else self.amount if not hasattr(self, "__credit"):
setattr(self, "__credit", None if self.is_debit else self.amount)
return getattr(self, "__credit")
@credit.setter
def credit(self, value: Decimal | None) -> None:
"""Sets the credit amount.
:param value: The credit amount.
:return: None.
"""
setattr(self, "__credit", value)
@property @property
def net_balance(self) -> Decimal: def net_balance(self) -> Decimal:
@ -750,13 +777,85 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__net_balance") return getattr(self, "__net_balance")
@net_balance.setter @net_balance.setter
def net_balance(self, net_balance: Decimal) -> None: def net_balance(self, value: Decimal) -> None:
"""Sets the net balance. """Sets the net balance.
:param net_balance: The net balance. :param value: The net balance.
:return: None. :return: None.
""" """
setattr(self, "__net_balance", net_balance) setattr(self, "__net_balance", value)
@property
def balance(self) -> Decimal:
"""Returns the balance.
:return: The balance.
"""
if not hasattr(self, "__balance"):
setattr(self, "__balance", Decimal("0"))
return getattr(self, "__balance")
@balance.setter
def balance(self, value: Decimal) -> None:
"""Sets the balance.
:param value: The balance.
:return: None.
"""
setattr(self, "__balance", value)
@property
def offsets(self) -> list[Self]:
"""Returns the offset items.
:return: The offset items.
"""
if not hasattr(self, "__offsets"):
cls: Type[Self] = self.__class__
offsets: list[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 is_offset(self) -> bool:
"""Returns whether the line item is an offset.
:return: True if the line item is an offset, or False otherwise.
"""
if not hasattr(self, "__is_offset"):
setattr(self, "__is_offset", False)
return getattr(self, "__is_offset")
@is_offset.setter
def is_offset(self, value: bool) -> None:
"""Sets whether the line item is an offset.
:param value: True if the line item is an offset, or False otherwise.
:return: None.
"""
setattr(self, "__is_offset", value)
@property
def match(self) -> 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, value: Self) -> None:
"""Sets the match of the line item.
:param value: The matcho of the line item.
:return: None.
"""
setattr(self, "__match", value)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
@ -773,6 +872,7 @@ class JournalEntryLineItem(db.Model):
self.journal_entry.date.month, self.journal_entry.date.month,
self.journal_entry.date.day), self.journal_entry.date.day),
"" if self.description is None else self.description, "" if self.description is None else self.description,
str(self.account),
format_amount(self.amount)] format_amount(self.amount)]
@ -780,27 +880,25 @@ class Option(db.Model):
"""An option.""" """An option."""
__tablename__ = "accounting_options" __tablename__ = "accounting_options"
"""The table name.""" """The table name."""
name = db.Column(db.String, nullable=False, primary_key=True) name: Mapped[str] = mapped_column(primary_key=True)
"""The name.""" """The name."""
value = db.Column(db.Text, nullable=False) value: Mapped[str] = mapped_column(db.Text)
"""The option value.""" """The option value."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The ID of the updator.""" """The last user who updated the record."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""

View File

@ -27,9 +27,11 @@ def init_app(app: Flask, url_prefix: str) -> None:
:param url_prefix: The URL prefix 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
app.register_blueprint(report_bp, url_prefix=url_prefix) app.register_blueprint(report_bp, url_prefix=url_prefix)

View File

@ -28,8 +28,8 @@ from .period import Period, get_period
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,9 +51,9 @@ 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) -> CurrentAccount: def to_python(self, value: str) -> CurrentAccount:
"""Converts an account code to an account. """Converts an account code to an account.
@ -77,3 +77,29 @@ class IncomeExpensesAccountConverter(BaseConverter):
:return: Its code. :return: Its code.
""" """
return value.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.
:param value: The account.
:return: Its code.
"""
return value.code

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -20,10 +20,11 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com). 2021/9/16 by imacat (imacat@nanoparma.com).
""" """
import typing as t import datetime as dt
from datetime import date from collections.abc import Callable
from accounting.models import JournalEntry from accounting.models import JournalEntry
from accounting.utils.timezone import get_tz_today
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
@ -32,13 +33,13 @@ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
class PeriodChooser: class PeriodChooser:
"""The period chooser.""" """The period chooser."""
def __init__(self, get_url: t.Callable[[Period], str]): def __init__(self, get_url: Callable[[Period], str]):
"""Constructs a period chooser. """Constructs a period chooser.
:param get_url: The callback to return the URL of the current report in :param get_url: The callback to return the URL of the current report in
a period. a period.
""" """
self.__get_url: t.Callable[[Period], str] = get_url self.__get_url: Callable[[Period], str] = get_url
"""The callback to return the URL of the current report in a period.""" """The callback to return the URL of the current report in a period."""
# Shortcut periods # Shortcut periods
@ -63,10 +64,10 @@ class PeriodChooser:
first: JournalEntry | None \ first: JournalEntry | None \
= JournalEntry.query.order_by(JournalEntry.date).first() = JournalEntry.query.order_by(JournalEntry.date).first()
start: date | None = None if first is None else first.date start: dt.date | None = None if first is None else first.date
# Attributes # Attributes
self.data_start: date | None = start self.data_start: dt.date | None = start
"""The start of the data.""" """The start of the data."""
self.has_data: bool = start is not None self.has_data: bool = start is not None
"""Whether there is any data.""" """Whether there is any data."""
@ -80,8 +81,8 @@ class PeriodChooser:
"""The available years.""" """The available years."""
if self.has_data: if self.has_data:
today: date = date.today() today: dt.date = get_tz_today()
self.has_last_month = start < date(today.year, today.month, 1) self.has_last_month = start < dt.date(today.year, today.month, 1)
self.has_last_year = start.year < today.year self.has_last_year = start.year < today.year
self.has_yesterday = start < today self.has_yesterday = start < today
if start.year < today.year - 1: if start.year < today.year - 1:

View File

@ -17,12 +17,12 @@
"""The period description composer. """The period description composer.
""" """
from datetime import date, timedelta import datetime as dt
from accounting.locale import gettext from accounting.locale import gettext
def get_desc(start: date | None, end: date | None) -> str: def get_desc(start: dt.date | None, end: dt.date | None) -> str:
"""Returns the period description. """Returns the period description.
:param start: The start of the period. :param start: The start of the period.
@ -46,7 +46,7 @@ def get_desc(start: date | None, end: date | None) -> str:
return __get_day_desc(start, end) return __get_day_desc(start, end)
def __get_since_desc(start: date) -> str: def __get_since_desc(start: dt.date) -> str:
"""Returns the description without the end day. """Returns the description without the end day.
:param start: The start of the period. :param start: The start of the period.
@ -67,7 +67,7 @@ def __get_since_desc(start: date) -> str:
return gettext("since %(start)s", start=get_start_desc()) return gettext("since %(start)s", start=get_start_desc())
def __get_until_desc(end: date) -> str: def __get_until_desc(end: dt.date) -> str:
"""Returns the description without the start day. """Returns the description without the start day.
:param end: The end of the period. :param end: The end of the period.
@ -81,14 +81,14 @@ def __get_until_desc(end: date) -> str:
""" """
if end.month == 12 and end.day == 31: if end.month == 12 and end.day == 31:
return str(end.year) return str(end.year)
if (end + timedelta(days=1)).day == 1: if (end + dt.timedelta(days=1)).day == 1:
return __format_month(end) return __format_month(end)
return __format_day(end) return __format_day(end)
return gettext("until %(end)s", end=get_end_desc()) return gettext("until %(end)s", end=get_end_desc())
def __get_year_desc(start: date, end: date) -> str: def __get_year_desc(start: dt.date, end: dt.date) -> str:
"""Returns the description as a year range. """Returns the description as a year range.
:param start: The start of the period. :param start: The start of the period.
@ -105,7 +105,7 @@ def __get_year_desc(start: date, end: date) -> str:
return __get_from_to_desc(start_text, str(end.year)) return __get_from_to_desc(start_text, str(end.year))
def __get_month_desc(start: date, end: date) -> str: def __get_month_desc(start: dt.date, end: dt.date) -> str:
"""Returns the description as a month range. """Returns the description as a month range.
:param start: The start of the period. :param start: The start of the period.
@ -113,7 +113,7 @@ def __get_month_desc(start: date, end: date) -> str:
:return: The description as a month range. :return: The description as a month range.
:raise ValueError: The period is not a month range. :raise ValueError: The period is not a month range.
""" """
if start.day != 1 or (end + timedelta(days=1)).day != 1: if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
raise ValueError raise ValueError
start_text: str = __format_month(start) start_text: str = __format_month(start)
if start.year == end.year and start.month == end.month: if start.year == end.year and start.month == end.month:
@ -123,7 +123,7 @@ def __get_month_desc(start: date, end: date) -> str:
return __get_from_to_desc(start_text, __format_month(end)) return __get_from_to_desc(start_text, __format_month(end))
def __get_day_desc(start: date, end: date) -> str: def __get_day_desc(start: dt.date, end: dt.date) -> str:
"""Returns the description as a day range. """Returns the description as a day range.
:param start: The start of the period. :param start: The start of the period.
@ -142,7 +142,7 @@ def __get_day_desc(start: date, end: date) -> str:
return __get_from_to_desc(start_text, __format_day(end)) return __get_from_to_desc(start_text, __format_day(end))
def __format_month(month: date) -> str: def __format_month(month: dt.date) -> str:
"""Formats a month. """Formats a month.
:param month: The month. :param month: The month.
@ -151,7 +151,7 @@ def __format_month(month: date) -> str:
return f"{month.year}/{month.month}" return f"{month.year}/{month.month}"
def __format_day(day: date) -> str: def __format_day(day: dt.date) -> str:
"""Formats a day. """Formats a day.
:param day: The day. :param day: The day.

View File

@ -18,14 +18,14 @@
""" """
import calendar import calendar
from datetime import date import datetime as dt
def month_end(day: date) -> date: def month_end(day: dt.date) -> dt.date:
"""Returns the end day of month for a date. """Returns the end day of month for a date.
:param day: The date. :param day: The date.
:return: The end day of the month of that day. :return: The end day of the month of that day.
""" """
last_day: int = calendar.monthrange(day.year, day.month)[1] last_day: int = calendar.monthrange(day.year, day.month)[1]
return date(day.year, day.month, last_day) return dt.date(day.year, day.month, last_day)

View File

@ -18,9 +18,10 @@
""" """
import calendar import calendar
import datetime as dt
import re import re
import typing as t from collections.abc import Callable
from datetime import date from typing import Type
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@ -39,7 +40,7 @@ def get_period(spec: str | None = None) -> Period:
""" """
if spec is None: if spec is None:
return ThisMonth() return ThisMonth()
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = { named_periods: dict[str, Type[Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(), "this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(), "last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(), "since-last-month": lambda: SinceLastMonth(),
@ -57,7 +58,7 @@ def get_period(spec: str | None = None) -> Period:
return Period(start, end) return Period(start, end)
def __parse_spec(text: str) -> tuple[date | None, date | None]: def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
"""Parses the period specification. """Parses the period specification.
:param text: The period specification. :param text: The period specification.
@ -84,7 +85,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
raise ValueError raise ValueError
def __get_start(year: str, month: str | None, day: str | None) -> date: def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
"""Returns the start of the period from the date representation. """Returns the start of the period from the date representation.
:param year: The year. :param year: The year.
@ -94,13 +95,13 @@ def __get_start(year: str, month: str | None, day: str | None) -> date:
:raise ValueError: When the date is invalid. :raise ValueError: When the date is invalid.
""" """
if day is not None: if day is not None:
return date(int(year), int(month), int(day)) return dt.date(int(year), int(month), int(day))
if month is not None: if month is not None:
return date(int(year), int(month), 1) return dt.date(int(year), int(month), 1)
return date(int(year), 1, 1) return dt.date(int(year), 1, 1)
def __get_end(year: str, month: str | None, day: str | None) -> date: def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
"""Returns the end of the period from the date representation. """Returns the end of the period from the date representation.
:param year: The year. :param year: The year.
@ -110,10 +111,10 @@ def __get_end(year: str, month: str | None, day: str | None) -> date:
:raise ValueError: When the date is invalid. :raise ValueError: When the date is invalid.
""" """
if day is not None: if day is not None:
return date(int(year), int(month), int(day)) return dt.date(int(year), int(month), int(day))
if month is not None: if month is not None:
year_n: int = int(year) year_n: int = int(year)
month_n: int = int(month) month_n: int = int(month)
day_n: int = calendar.monthrange(year_n, month_n)[1] day_n: int = calendar.monthrange(year_n, month_n)[1]
return date(year_n, month_n, day_n) return dt.date(year_n, month_n, day_n)
return date(int(year), 12, 31) return dt.date(int(year), 12, 31)

View File

@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com). 2021/9/16 by imacat (imacat@nanoparma.com).
""" """
import typing as t import datetime as dt
from datetime import date, timedelta from typing import Self
from .description import get_desc from .description import get_desc
from .month_end import month_end from .month_end import month_end
@ -31,18 +31,18 @@ from .specification import get_spec
class Period: class Period:
"""A date period.""" """A date period."""
def __init__(self, start: date | None, end: date | None): def __init__(self, start: dt.date | None, end: dt.date | None):
"""Constructs a new date period. """Constructs a new date period.
:param start: The start date, or None from the very beginning. :param start: The start date, or None from the very beginning.
:param end: The end date, or None till no end. :param end: The end date, or None till no end.
""" """
self.start: date | None = start self.start: dt.date | None = start
"""The start of the period.""" """The start of the period."""
self.end: date | None = end self.end: dt.date | None = end
"""The end of the period.""" """The end of the period."""
self.is_default: bool = False self.is_default: bool = False
"""Whether the is the default period.""" """Whether this is the default period."""
self.is_this_month: bool = False self.is_this_month: bool = False
"""Whether the period is this month.""" """Whether the period is this month."""
self.is_last_month: bool = False self.is_last_month: bool = False
@ -95,8 +95,8 @@ class Period:
self.is_a_month = self.start.day == 1 \ self.is_a_month = self.start.day == 1 \
and self.end == month_end(self.start) and self.end == month_end(self.start)
self.is_type_month = self.is_a_month self.is_type_month = self.is_a_month
self.is_a_year = self.start == date(self.start.year, 1, 1) \ self.is_a_year = self.start == dt.date(self.start.year, 1, 1) \
and self.end == date(self.start.year, 12, 31) and self.end == dt.date(self.start.year, 12, 31)
self.is_a_day = self.start == self.end self.is_a_day = self.start == self.end
def is_year(self, year: int) -> bool: def is_year(self, year: int) -> bool:
@ -119,11 +119,11 @@ class Period:
and not self.is_a_day and not self.is_a_day
@property @property
def before(self) -> t.Self | None: def before(self) -> Self | None:
"""Returns the period before this period. """Returns the period before this period.
:return: The period before this period. :return: The period before this period.
""" """
if self.start is None: if self.start is None:
return None return None
return Period(None, self.start - timedelta(days=1)) return Period(None, self.start - dt.timedelta(days=1))

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -17,9 +17,10 @@
"""The named shortcut periods. """The named shortcut periods.
""" """
from datetime import date, timedelta import datetime as dt
from accounting.locale import gettext from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
from .month_end import month_end from .month_end import month_end
from .period import Period from .period import Period
@ -27,8 +28,8 @@ from .period import Period
class ThisMonth(Period): class ThisMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = get_tz_today()
this_month_start: date = date(today.year, today.month, 1) this_month_start: dt.date = dt.date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today)) super().__init__(this_month_start, month_end(today))
self.is_default = True self.is_default = True
self.is_this_month = True self.is_this_month = True
@ -43,13 +44,13 @@ class ThisMonth(Period):
class LastMonth(Period): class LastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = get_tz_today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
year = year - 1 year = year - 1
month = 12 month = 12
start: date = date(year, month, 1) start: dt.date = dt.date(year, month, 1)
super().__init__(start, month_end(start)) super().__init__(start, month_end(start))
self.is_last_month = True self.is_last_month = True
@ -63,13 +64,13 @@ class LastMonth(Period):
class SinceLastMonth(Period): class SinceLastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = get_tz_today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
year = year - 1 year = year - 1
month = 12 month = 12
start: date = date(year, month, 1) start: dt.date = dt.date(year, month, 1)
super().__init__(start, None) super().__init__(start, None)
self.is_since_last_month = True self.is_since_last_month = True
@ -82,9 +83,9 @@ class SinceLastMonth(Period):
class ThisYear(Period): class ThisYear(Period):
"""The period of this year.""" """The period of this year."""
def __init__(self): def __init__(self):
year: int = date.today().year year: int = get_tz_today().year
start: date = date(year, 1, 1) start: dt.date = dt.date(year, 1, 1)
end: date = date(year, 12, 31) end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end) super().__init__(start, end)
self.is_this_year = True self.is_this_year = True
@ -97,9 +98,9 @@ class ThisYear(Period):
class LastYear(Period): class LastYear(Period):
"""The period of last year.""" """The period of last year."""
def __init__(self): def __init__(self):
year: int = date.today().year year: int = get_tz_today().year
start: date = date(year - 1, 1, 1) start: dt.date = dt.date(year - 1, 1, 1)
end: date = date(year - 1, 12, 31) end: dt.date = dt.date(year - 1, 12, 31)
super().__init__(start, end) super().__init__(start, end)
self.is_last_year = True self.is_last_year = True
@ -112,7 +113,7 @@ class LastYear(Period):
class Today(Period): class Today(Period):
"""The period of today.""" """The period of today."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = get_tz_today()
super().__init__(today, today) super().__init__(today, today)
self.is_today = True self.is_today = True
@ -125,7 +126,7 @@ class Today(Period):
class Yesterday(Period): class Yesterday(Period):
"""The period of yesterday.""" """The period of yesterday."""
def __init__(self): def __init__(self):
yesterday: date = date.today() - timedelta(days=1) yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
super().__init__(yesterday, yesterday) super().__init__(yesterday, yesterday)
self.is_yesterday = True self.is_yesterday = True
@ -163,6 +164,6 @@ class YearPeriod(Period):
:param year: The year. :param year: The year.
""" """
start: date = date(year, 1, 1) start: dt.date = dt.date(year, 1, 1)
end: date = date(year, 12, 31) end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end) super().__init__(start, end)

View File

@ -17,10 +17,10 @@
"""The period specification composer. """The period specification composer.
""" """
from datetime import date, timedelta import datetime as dt
def get_spec(start: date | None, end: date | None) -> str: def get_spec(start: dt.date | None, end: dt.date | None) -> str:
"""Returns the period specification. """Returns the period specification.
:param start: The start of the period. :param start: The start of the period.
@ -44,7 +44,7 @@ def get_spec(start: date | None, end: date | None) -> str:
return __get_day_spec(start, end) return __get_day_spec(start, end)
def __get_since_spec(start: date) -> str: def __get_since_spec(start: dt.date) -> str:
"""Returns the period specification without the end day. """Returns the period specification without the end day.
:param start: The start of the period. :param start: The start of the period.
@ -57,7 +57,7 @@ def __get_since_spec(start: date) -> str:
return start.strftime("%Y-%m-%d-") return start.strftime("%Y-%m-%d-")
def __get_until_spec(end: date) -> str: def __get_until_spec(end: dt.date) -> str:
"""Returns the period specification without the start day. """Returns the period specification without the start day.
:param end: The end of the period. :param end: The end of the period.
@ -65,12 +65,12 @@ def __get_until_spec(end: date) -> str:
""" """
if end.month == 12 and end.day == 31: if end.month == 12 and end.day == 31:
return end.strftime("-%Y") return end.strftime("-%Y")
if (end + timedelta(days=1)).day == 1: if (end + dt.timedelta(days=1)).day == 1:
return end.strftime("-%Y-%m") return end.strftime("-%Y-%m")
return end.strftime("-%Y-%m-%d") return end.strftime("-%Y-%m-%d")
def __get_year_spec(start: date, end: date) -> str: def __get_year_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification as a year range. """Returns the period specification as a year range.
:param start: The start of the period. :param start: The start of the period.
@ -88,7 +88,7 @@ def __get_year_spec(start: date, end: date) -> str:
return f"{start_spec}-{end_spec}" return f"{start_spec}-{end_spec}"
def __get_month_spec(start: date, end: date) -> str: def __get_month_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification as a month range. """Returns the period specification as a month range.
:param start: The start of the period. :param start: The start of the period.
@ -96,7 +96,7 @@ def __get_month_spec(start: date, end: date) -> str:
:return: The period specification as a month range. :return: The period specification as a month range.
:raise ValueError: The period is not a month range. :raise ValueError: The period is not a month range.
""" """
if start.day != 1 or (end + timedelta(days=1)).day != 1: if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
raise ValueError raise ValueError
start_spec: str = start.strftime("%Y-%m") start_spec: str = start.strftime("%Y-%m")
if start.year == end.year and start.month == end.month: if start.year == end.year and start.month == end.month:
@ -105,7 +105,7 @@ def __get_month_spec(start: date, end: date) -> str:
return f"{start_spec}-{end_spec}" return f"{start_spec}-{end_spec}"
def __get_day_spec(start: date, end: date) -> str: def __get_day_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification as a day range. """Returns the period specification as a day range.
:param start: The start of the period. :param start: The start of the period.

View File

@ -145,6 +145,7 @@ class AccountCollector:
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}), .filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351", Account.base_code == "3351",
Account.base_code == "3353")).all() Account.base_code == "3353")).all()
"""The accounts."""
account_by_id: dict[int, Account] \ account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts} = {x.id: x for x in self.__all_accounts}
self.accounts: list[ReportAccount] \ self.accounts: list[ReportAccount] \
@ -154,6 +155,7 @@ class AccountCollector:
account_by_id[x.id], account_by_id[x.id],
self.__period)) self.__period))
for x in account_balances] for x in account_balances]
"""The accounts on the balance sheet."""
self.__add_accumulated() self.__add_accumulated()
self.__add_current_period() self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no)) self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
@ -452,11 +454,11 @@ class BalanceSheet(BaseReport):
:return: The CSV rows for the section. :return: The CSV rows for the section.
""" """
rows: list[CSVHalfRow] \ rows: list[CSVHalfRow] \
= [CSVHalfRow(section.title.title.title(), None)] = [CSVHalfRow(section.title.title, None)]
for subsection in section.subsections: for subsection in section.subsections:
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None)) rows.append(CSVHalfRow(f" {subsection.title.title}", None))
for account in subsection.accounts: for account in subsection.accounts:
rows.append(CSVHalfRow(f" {str(account.account).title()}", rows.append(CSVHalfRow(f" {str(account.account)}",
account.amount)) account.amount))
return rows return rows

View File

@ -17,7 +17,7 @@
"""The income and expenses log. """The income and expenses log.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -37,7 +37,6 @@ 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.current_account import CurrentAccount from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -54,7 +53,7 @@ class ReportLineItem:
"""Whether this is the brought-forward line item.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total line item.""" """Whether this is the total line item."""
self.date: date | None = None self.date: dt.date | None = None
"""The date.""" """The date."""
self.account: Account | None = None self.account: Account | None = None
"""The account.""" """The account."""
@ -122,8 +121,7 @@ class LineItemCollector:
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select = sa.Select(balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.__currency.code,
== self.__currency.code),
self.__account_condition, self.__account_condition,
JournalEntry.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
@ -215,7 +213,7 @@ class LineItemCollector:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: date | str | None, def __init__(self, date: dt.date | str | None,
account: str | None, account: str | None,
description: str | None, description: str | None,
income: str | Decimal | None, income: str | Decimal | None,
@ -224,7 +222,7 @@ class CSVRow(BaseCSVRow):
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param journal_entry_date: The journal entry date. :param date: The journal entry date.
:param account: The account. :param account: The account.
:param description: The description. :param description: The description.
:param income: The income. :param income: The income.
@ -232,7 +230,7 @@ class CSVRow(BaseCSVRow):
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = journal_entry_date self.date: dt.date | str | None = date
"""The date.""" """The date."""
self.account: str | None = account self.account: str | None = account
"""The account.""" """The account."""
@ -347,8 +345,7 @@ class PageParams(BasePageParams):
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\ .join(Account)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.currency.code,
== self.currency.code),
CurrentAccount.sql_condition())\ CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id) .group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x), options.extend([OptionLink(str(x),
@ -410,13 +407,13 @@ class IncomeExpenses(BaseReport):
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),
self.__brought_forward.description, 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.description, rows.extend([CSVRow(x.date, str(x.account), x.description,
x.income, x.expense, x.balance, x.note) x.income, x.expense, x.balance, x.note)
for x in self.__line_items]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:

View File

@ -106,6 +106,7 @@ class Section:
"""The subsections in the section.""" """The subsections in the section."""
self.accumulated: AccumulatedTotal \ self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title) = AccumulatedTotal(accumulated_title)
"""The accumulated total."""
@property @property
def total(self) -> Decimal: def total(self) -> Decimal:
@ -225,12 +226,12 @@ class IncomeStatement(BaseReport):
for x in balances})).all() for x in balances})).all()
total_titles: dict[str, str] \ total_titles: dict[str, str] \
= {"4": gettext("total operating revenue"), = {"4": gettext("Total Operating Revenue"),
"5": gettext("gross income"), "5": gettext("Gross Income"),
"6": gettext("operating income"), "6": gettext("Operating Income"),
"7": gettext("before tax income"), "7": gettext("Before Tax Income"),
"8": gettext("after tax income"), "8": gettext("After Tax Income"),
"9": gettext("net income or loss for current period")} "9": gettext("Net Income or Loss for Current Period")}
sections: dict[str, Section] \ sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles} = {x.code: Section(x, total_titles[x.code]) for x in titles}
@ -300,14 +301,14 @@ class IncomeStatement(BaseReport):
total_str: str = gettext("Total") total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))] rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections: for section in self.__sections:
rows.append(CSVRow(str(section.title).title(), None)) rows.append(CSVRow(str(section.title), None))
for subsection in section.subsections: for subsection in section.subsections:
rows.append(CSVRow(f" {str(subsection.title).title()}", None)) rows.append(CSVRow(f" {str(subsection.title)}", None))
for account in subsection.accounts: for account in subsection.accounts:
rows.append(CSVRow(f" {str(account.account).title()}", rows.append(CSVRow(f" {str(account.account)}",
account.amount)) account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total)) rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title.title(), rows.append(CSVRow(section.accumulated.title,
section.accumulated.amount)) section.accumulated.amount))
rows.append(CSVRow(None, None)) rows.append(CSVRow(None, None))
rows = rows[:-1] rows = rows[:-1]

View File

@ -17,7 +17,7 @@
"""The journal. """The journal.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -67,7 +67,7 @@ class ReportLineItem:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: str | date, def __init__(self, journal_entry_date: str | dt.date,
currency: str, currency: str,
account: str, account: str,
description: str | None, description: str | None,
@ -77,12 +77,14 @@ class CSVRow(BaseCSVRow):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param journal_entry_date: The journal entry date. :param journal_entry_date: The journal entry date.
:param currency: The currency.
:param account: The account.
:param description: The description. :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 = journal_entry_date self.date: str | dt.date = journal_entry_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""
@ -116,6 +118,7 @@ class PageParams(BasePageParams):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param period: The period. :param period: The period.
:param pagination: The pagination.
:param line_items: The line items. :param line_items: The line items.
""" """
self.period: Period = period self.period: Period = period
@ -157,7 +160,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
gettext("Debit"), gettext("Credit"), gettext("Debit"), gettext("Credit"),
gettext("Note"))] gettext("Note"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code, rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
str(x.account).title(), x.description, str(x.account), x.description,
x.debit, x.credit, x.journal_entry.note) x.debit, x.credit, x.journal_entry.note)
for x in line_items]) for x in line_items])
return rows return rows

View File

@ -17,7 +17,7 @@
"""The ledger. """The ledger.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -37,7 +37,6 @@ from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url from accounting.report.utils.urls import ledger_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -53,7 +52,7 @@ class ReportLineItem:
"""Whether this is the brought-forward line item.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total line item.""" """Whether this is the total line item."""
self.date: date | None = None self.date: dt.date | None = None
"""The date.""" """The date."""
self.description: str | None = None self.description: str | None = None
"""The description.""" """The description."""
@ -118,10 +117,8 @@ class LineItemCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\ select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.__currency.code,
== self.__currency.code), JournalEntryLineItem.account_id == self.__account.id,
be(JournalEntryLineItem.account_id
== self.__account.id),
JournalEntry.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:
@ -199,7 +196,7 @@ class LineItemCollector:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: date | str | None, def __init__(self, date: dt.date | str | None,
description: str | None, description: str | None,
debit: str | Decimal | None, debit: str | Decimal | None,
credit: str | Decimal | None, credit: str | Decimal | None,
@ -207,14 +204,14 @@ class CSVRow(BaseCSVRow):
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param journal_entry_date: The journal entry date. :param date: The journal entry date.
:param description: The description. :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 = journal_entry_date self.date: dt.date | str | None = date
"""The date.""" """The date."""
self.description: str | None = description self.description: str | None = description
"""The description.""" """The description."""
@ -313,8 +310,7 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.currency.code)\
== self.currency.code))\
.group_by(JournalEntryLineItem.account_id) .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)

View File

@ -17,7 +17,7 @@
"""The search. """The search.
""" """
from datetime import datetime import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -32,7 +32,6 @@ from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download from accounting.report.utils.csv_export import csv_download
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows from .journal import get_csv_rows
@ -125,30 +124,33 @@ class LineItemCollector:
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.note.icontains(k)] = [JournalEntry.note.icontains(k)]
journal_entry_date: datetime date: dt.datetime
try: try:
journal_entry_date = datetime.strptime(k, "%Y") date = dt.datetime.strptime(k, "%Y")
conditions.append( conditions.append(
be(sa.extract("year", JournalEntry.date) sa.extract("year", JournalEntry.date) == date.year)
== journal_entry_date.year))
except ValueError: except ValueError:
pass pass
try: try:
journal_entry_date = datetime.strptime(k, "%Y/%m") date = dt.datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("year", JournalEntry.date) sa.extract("year", JournalEntry.date) == date.year,
== journal_entry_date.year, sa.extract("month", JournalEntry.date) == date.month))
sa.extract("month", JournalEntry.date)
== journal_entry_date.month))
except ValueError: except ValueError:
pass pass
try: try:
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("month", JournalEntry.date) sa.extract("month", JournalEntry.date) == date.month,
== journal_entry_date.month, sa.extract("day", JournalEntry.date) == date.day))
sa.extract("day", JournalEntry.date) except ValueError:
== journal_entry_date.day)) pass
try:
date = dt.datetime.strptime(k, "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("year", JournalEntry.date) == date.year,
sa.extract("month", JournalEntry.date) == date.month,
sa.extract("day", JournalEntry.date) == date.day))
except ValueError: except ValueError:
pass pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions)) return sa.select(JournalEntry.id).filter(sa.or_(*conditions))

View File

@ -224,7 +224,7 @@ class TrialBalance(BaseReport):
""" """
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"), rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
gettext("Credit"))] gettext("Credit"))]
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit) rows.extend([CSVRow(str(x.account), x.debit, x.credit)
for x in self.__accounts]) for x in self.__accounts])
rows.append(CSVRow(gettext("Total"), self.__total.debit, rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit)) self.__total.credit))

View File

@ -0,0 +1,216 @@
# 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.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
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, \
get_net_balances
from accounting.report.utils.urls import unapplied_url
from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | dt.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 | dt.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 | dt.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, currency: Currency,
account: Account,
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
@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, currency=self.currency,
account=self.account)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: unapplied_url(x, self.account), self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unapplied_url(self.currency, None),
False)]
options.extend(
[OptionLink(str(x), unapplied_url(self.currency, x),
x.id == self.account.id)
for x in get_accounts_with_unapplied(self.currency)])
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, currency: Currency, account: Account):
"""Constructs the unapplied original line items.
:param currency: The currency.
:param account: The account.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__line_items: list[JournalEntryLineItem] \
= self.__query_line_items()
"""The line items."""
def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the line items.
:return: The line items.
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(JournalEntryLineItem.id.in_(net_balances)) \
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
return line_items
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unapplied-{currency}-{account}.csv"\
.format(currency=self.__currency.code, account=self.__account.code)
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(currency=self.__currency,
account=self.__account,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unapplied.html",
report=params)

View File

@ -0,0 +1,155 @@
# 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.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Currency, 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 | dt.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, currency: Currency, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param accounts: The accounts.
"""
self.currency: Currency = currency
"""The currency."""
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, currency=self.currency,
account=None)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(lambda x: unapplied_url(x, None),
self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unapplied_url(self.currency, None),
True)]
options.extend(
[OptionLink(str(x), unapplied_url(self.currency, 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), x.count) for x in accounts])
return rows
class AccountsWithUnappliedOriginalLineItems(BaseReport):
"""The accounts with unapplied original line items."""
def __init__(self, currency: Currency):
"""Constructs the outstanding balances.
:param currency: The currency.
"""
self.__currency: Currency = currency
"""The currency."""
self.__accounts: list[Account] = get_accounts_with_unapplied(currency)
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "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(currency=self.__currency,
accounts=self.__accounts))

View File

@ -0,0 +1,214 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# 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 unmatched offsets.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from flask_babel import LazyString
from accounting.locale import gettext
from accounting.models import Currency, 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.offset_matcher import OffsetMatcher, OffsetPair
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.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_url
from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | dt.date, currency: str,
description: str | None, debit: str | Decimal,
credit: str | Decimal, 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 debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
"""
self.date: str | dt.date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.description: str | None = description
"""The description."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.balance: str | Decimal = balance
"""The balance."""
@property
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.description, self.debit,
self.credit, self.balance]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: Account,
match_status: str | LazyString,
matched_pairs: list[OffsetPair],
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param match_status: The match status message.
:param matched_pairs: A list of matched pairs.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.match_status: str | LazyString = match_status
"""The match status message."""
self.matched_pairs: list[OffsetPair] = matched_pairs
"""A list of matched pairs."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
@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.UNMATCHED, currency=self.currency,
account=self.account)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: unmatched_url(x, self.account), self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unmatched_url(self.currency, None),
False)]
options.extend(
[OptionLink(str(x), unmatched_url(self.currency, x),
x.id == self.account.id)
for x in get_accounts_with_unmatched(self.currency)])
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("Debit"),
gettext("Credit"), gettext("Balance"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
x.description, x.debit, x.credit, x.balance)
for x in line_items])
return rows
class UnmatchedOffsets(BaseReport):
"""The unmatched offsets."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the unmatched offsets.
:param currency: The currency.
:param account: The account.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
offset_matcher: OffsetMatcher \
= OffsetMatcher(self.__currency, self.__account)
self.__line_items: list[JournalEntryLineItem] \
= offset_matcher.line_items
"""The line items."""
self.__match_status: str | LazyString = offset_matcher.status
"""The match status message."""
self.__matched_pairs: list[OffsetPair] = offset_matcher.matched_pairs
"""A list of matched pairs."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unmatched-{currency}-{account}.csv"\
.format(currency=self.__currency.code, account=self.__account.code)
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(currency=self.__currency,
account=self.__account,
match_status=self.__match_status,
matched_pairs=self.__matched_pairs,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unmatched.html",
report=params)

View File

@ -0,0 +1,156 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# 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 unmatched offsets.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Currency, 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.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_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 | dt.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, currency: Currency, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param accounts: The accounts.
"""
self.currency: Currency = currency
"""The currency."""
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.UNMATCHED, currency=self.currency,
account=None)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(lambda x: unmatched_url(x, None),
self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unmatched_url(self.currency, None),
True)]
options.extend(
[OptionLink(str(x), unmatched_url(self.currency, 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), x.count) for x in accounts])
return rows
class AccountsWithUnmatchedOffsets(BaseReport):
"""The accounts with unmatched offsets."""
def __init__(self, currency: Currency):
"""Constructs the outstanding balances.
:param currency: The currency.
"""
self.__currency: Currency = currency
"""The currency."""
self.__accounts: list[Account] \
= get_accounts_with_unmatched(currency)
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unmatched-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/unmatched-accounts.html",
report=PageParams(currency=self.__currency,
accounts=self.__accounts))

View File

@ -17,8 +17,9 @@
"""The page parameters of a report. """The page parameters of a report.
""" """
import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Type
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \ from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse urlunparse
@ -52,7 +53,7 @@ class BasePageParams(ABC):
""" """
@property @property
def journal_entry_types(self) -> t.Type[JournalEntryType]: def journal_entry_types(self) -> Type[JournalEntryType]:
"""Returns the journal entry types. """Returns the journal entry types.
:return: The journal entry types. :return: The journal entry types.
@ -72,7 +73,7 @@ class BasePageParams(ABC):
return urlunparse(parts) return urlunparse(parts)
@staticmethod @staticmethod
def _get_currency_options(get_url: t.Callable[[Currency], str], def _get_currency_options(get_url: Callable[[Currency], str],
active_currency: Currency) -> list[OptionLink]: active_currency: Currency) -> list[OptionLink]:
"""Returns the currency options. """Returns the currency options.

View File

@ -18,10 +18,11 @@
""" """
import csv import csv
import datetime as dt
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
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
@ -76,7 +77,7 @@ def period_spec(period: Period) -> str:
return f"{start}-{end}" return f"{start}-{end}"
def __get_start_str(start: date | None) -> str | None: def __get_start_str(start: dt.date | None) -> str | None:
"""Returns the string representation of the start date. """Returns the string representation of the start date.
:param start: The start date. :param start: The start date.
@ -92,7 +93,7 @@ def __get_start_str(start: date | None) -> str | None:
return start.strftime("%Y%m%d") return start.strftime("%Y%m%d")
def __get_end_str(end: date | None) -> str | None: def __get_end_str(end: dt.date | None) -> str | None:
"""Returns the string representation of the end date. """Returns the string representation of the end date.
:param end: The end date. :param end: The end date.
@ -103,6 +104,6 @@ def __get_end_str(end: date | None) -> str | None:
return None return None
if end.month == 12 and end.day == 31: if end.month == 12 and end.day == 31:
return str(end.year) return str(end.year)
if (end + timedelta(days=1)).day == 1: if (end + dt.timedelta(days=1)).day == 1:
return end.strftime("%Y%m") return end.strftime("%Y%m")
return end.strftime("%Y%m%d") return end.strftime("%Y%m%d")

View File

@ -0,0 +1,178 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the unmatched offset management.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from sqlalchemy.orm import selectinload
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.utils.unapplied import get_net_balances
class OffsetPair:
"""A pair of an original line item and its offset."""
def __init__(self, original_line_item: JournalEntryLineItem,
offset: JournalEntryLineItem):
"""Constructs a pair of an original line item and its offset.
:param original_line_item: The original line item.
:param offset: The offset.
"""
self.original_line_item: JournalEntryLineItem = original_line_item
"""The original line item."""
self.offset: JournalEntryLineItem = offset
"""The offset."""
class OffsetMatcher:
"""The offset matcher."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the offset matcher.
:param currency: The currency.
:param account: The account.
"""
self.__currency: Account = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.matched_pairs: list[OffsetPair] = []
"""A list of matched pairs."""
self.line_items: list[JournalEntryLineItem] = []
"""The unapplied debits or credits and unmatched offsets."""
self.unapplied: list[JournalEntryLineItem] = []
"""The unapplied debits or credits."""
self.unmatched: list[JournalEntryLineItem] = []
"""The unmatched offsets."""
self.__find_matches()
def __find_matches(self) -> None:
"""Finds the matched original line items and their offsets.
:return: None.
"""
self.__get_line_items()
if len(self.unapplied) == 0 or len(self.unmatched) == 0:
return
remains: list[JournalEntryLineItem] = self.unmatched.copy()
for original_item in self.unapplied:
offset_candidates: list[JournalEntryLineItem] \
= [x for x in remains
if (x.journal_entry.date > original_item.journal_entry.date
or (x.journal_entry.date
== original_item.journal_entry.date
and x.journal_entry.no
> original_item.journal_entry.no))
and x.currency_code == original_item.currency_code
and x.description == original_item.description
and x.amount == original_item.net_balance]
if len(offset_candidates) == 0:
continue
self.matched_pairs.append(
OffsetPair(original_item, offset_candidates[0]))
original_item.match = offset_candidates[0]
offset_candidates[0].match = original_item
remains.remove(offset_candidates[0])
def __get_line_items(self) -> None:
"""Returns the unapplied original line items and unmatched offsets of
the account.
:return: The unapplied original line items and unmatched offsets of the
account.
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
unmatched_offset_condition: sa.BinaryExpression \
= sa.and_(Account.id == self.__account.id,
JournalEntryLineItem.currency_code
== self.__currency.code,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))
self.line_items = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition)) \
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in self.line_items:
line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items if not x.is_offset]
for line_item in self.unapplied:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
self.unmatched = [x for x in self.line_items if x.is_offset]
self.__populate_accumulated_balances()
def __populate_accumulated_balances(self) -> None:
"""Populates the accumulated balances of the line items.
:return: None.
"""
balance: Decimal = Decimal("0")
for line_item in self.line_items:
amount: Decimal = line_item.amount if line_item.is_offset \
else line_item.net_balance
if line_item.is_debit:
line_item.debit = amount
line_item.credit = None
balance = balance + amount
else:
line_item.debit = None
line_item.credit = amount
balance = balance - amount
line_item.balance = balance
@property
def status(self) -> str | LazyString:
"""Returns the match status message.
:return: The match status message.
"""
if len(self.unmatched) == 0:
return lazy_gettext("There is no unmatched offset.")
if len(self.matched_pairs) == 0:
return lazy_gettext(
"%(total)s unmatched offsets without original items.",
total=len(self.unmatched))
return lazy_gettext(
"%(matches)s unmatched offsets out of %(total)s"
" can match with their original items.",
matches=len(self.matched_pairs),
total=len(self.unmatched))
def match(self) -> None:
"""Matches the original line items with offsets.
:return: None.
"""
for pair in self.matched_pairs:
pair.offset.original_line_item_id = pair.original_line_item.id

View File

@ -21,7 +21,7 @@ This file is largely taken from the NanoParma ERP project, first written in
""" """
import re import re
import typing as t from collections.abc import Iterator
from flask_babel import LazyString from flask_babel import LazyString
@ -31,10 +31,12 @@ 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.current_account import CurrentAccount
from accounting.utils.permission import can_edit
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, unmatched_url
class ReportChooser: class ReportChooser:
@ -74,6 +76,9 @@ class ReportChooser:
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)
if can_edit():
self.__reports.append(self.__unmatched)
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
@ -151,7 +156,41 @@ 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")
def __iter__(self) -> t.Iterator[OptionLink]: @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 Items"),
unapplied_url(self.__currency, None),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unapplied Items"),
unapplied_url(self.__currency, self.__account),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
@property
def __unmatched(self) -> OptionLink:
"""Returns the unmatched offsets.
:return: The unmatched offsets.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, None),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question")
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, self.__account),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question")
def __iter__(self) -> Iterator[OptionLink]:
"""Returns the iteration of the reports. """Returns the iteration of the reports.
:return: The iteration of the reports. :return: The iteration of the reports.

View File

@ -34,5 +34,9 @@ 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."""
UNMATCHED: str = "unmatched"
"""The unmatched offsets."""
SEARCH: str = "search" SEARCH: str = "search"
"""The search.""" """The search."""

View File

@ -0,0 +1,104 @@
# 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.
"""
from decimal import Decimal
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(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(JournalEntry).join(Account)\
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
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
def get_net_balances(currency: Currency, account: Account) \
-> dict[int, Decimal | None]:
"""Returns the net balances of the unapplied line items of the account.
:param currency: The currency.
:param account: The account.
:return: The net balances of the unapplied line items of the account.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(offset.c.is_debit == JournalEntryLineItem.is_debit,
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance) \
.join(JournalEntry).join(Account) \
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \
.filter(Account.id == account.id,
JournalEntryLineItem.currency_code == currency.code,
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))
return {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}

View File

@ -0,0 +1,54 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unmatched offset utilities.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets, with the "count" property set
to the number of unmatched offsets.
"""
count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))\
.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

@ -47,9 +47,10 @@ 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-default")
return url_for("accounting-report.ledger", return url_for("accounting-report.ledger",
currency=currency, account=account, currency=currency, account=account,
period=period) period=period)
@ -68,9 +69,6 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
and account.code == options.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-default",
currency=currency, account=account)
return url_for("accounting-report.income-expenses", return url_for("accounting-report.income-expenses",
currency=currency, account=account, currency=currency, account=account,
period=period) period=period)
@ -83,9 +81,8 @@ 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)
@ -97,9 +94,8 @@ 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)
@ -111,8 +107,41 @@ 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(currency: Currency, account: Account | None) -> str:
"""Returns the URL of the unapplied original line items.
:param currency: The currency.
: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:
if currency.code == default_currency_code():
return url_for("accounting-report.unapplied-accounts-default")
return url_for("accounting-report.unapplied-accounts",
currency=currency)
return url_for("accounting-report.unapplied",
currency=currency, account=account)
def unmatched_url(currency: Currency, account: Account | None) -> str:
"""Returns the URL of the unmatched offset line items.
:param currency: The currency.
:param account: The account, or None to list the accounts with unmatched
offset line items.
:return: The URL of the unmatched offset line items.
"""
if account is None:
if currency.code == default_currency_code():
return url_for("accounting-report.unmatched-accounts-default")
return url_for("accounting-report.unmatched-accounts",
currency=currency)
return url_for("accounting-report.unmatched",
currency=currency, account=account)

View File

@ -17,18 +17,27 @@
"""The views for the report management. """The views for the report management.
""" """
from flask import Blueprint, request, Response from flask import Blueprint, request, Response, redirect, flash
from accounting import db from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account from accounting.models import Currency, Account
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.cast import s
from accounting.utils.current_account import CurrentAccount from accounting.utils.current_account import CurrentAccount
from accounting.utils.next_uri import or_next
from accounting.utils.options import options from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view, can_edit
from .period import Period, get_period
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 .reports.unmatched import UnmatchedOffsets
from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
from .template_filters import format_amount from .template_filters import format_amount
from .utils.offset_matcher import OffsetMatcher
from .utils.urls import unmatched_url
bp: Blueprint = Blueprint("accounting-report", __name__) bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""
@ -42,10 +51,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()),
options.default_ie_account,
get_period())
@bp.get("journal", endpoint="journal-default") @bp.get("journal", endpoint="journal-default")
@ -81,17 +87,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>",
@ -124,23 +128,21 @@ 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, account: CurrentAccount) \ def get_default_income_expenses() -> str | Response:
-> 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: CurrentAccount, def get_income_expenses(currency: Currency, account: CurrentAccount,
period: Period) -> str | Response: period: Period) -> str | Response:
@ -169,16 +171,15 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
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 +208,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 +246,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 +285,130 @@ def __get_balance_sheet(currency: Currency, period: Period) \
return report.html() return report.html()
@bp.get("unapplied", endpoint="unapplied-accounts-default")
@has_permission(can_view)
def get_default_unapplied_accounts() -> str | Response:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
return __get_unapplied_accounts(
db.session.get(Currency, default_currency_code()))
@bp.get("unapplied/<currency:currency>", endpoint="unapplied-accounts")
@has_permission(can_view)
def get_unapplied_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items.
"""
return __get_unapplied_accounts(currency)
def __get_unapplied_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items.
"""
report: AccountsWithUnappliedOriginalLineItems \
= AccountsWithUnappliedOriginalLineItems(currency)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unapplied/<currency:currency>/<needOffsetAccount:account>",
endpoint="unapplied")
@has_permission(can_view)
def get_unapplied(currency: Currency, account: Account) -> str | Response:
"""Returns the unapplied original line items.
:param currency: The currency.
:param account: The Account.
:return: The unapplied original line items in the period.
"""
report: UnappliedOriginalLineItems \
= UnappliedOriginalLineItems(currency, account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unmatched", endpoint="unmatched-accounts-default")
@has_permission(can_edit)
def get_default_unmatched_accounts() -> str | Response:
"""Returns the accounts with unmatched offsets.
:return: The accounts with unmatched offsets.
"""
return __get_unmatched_accounts(
db.session.get(Currency, default_currency_code()))
@bp.get("unmatched/<currency:currency>", endpoint="unmatched-accounts")
@has_permission(can_edit)
def get_unmatched_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets.
"""
return __get_unmatched_accounts(currency)
def __get_unmatched_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets.
"""
report: AccountsWithUnmatchedOffsets \
= AccountsWithUnmatchedOffsets(currency)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unmatched/<currency:currency>/<needOffsetAccount:account>",
endpoint="unmatched")
@has_permission(can_edit)
def get_unmatched(currency: Currency, account: Account) -> str | Response:
"""Returns the unmatched offsets.
:param currency: The currency.
:param account: The Account.
:return: The unmatched offsets in the period.
"""
report: UnmatchedOffsets = UnmatchedOffsets(currency, account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
endpoint="match-offsets")
@has_permission(can_edit)
def match_offsets(currency: Currency, account: Account) -> redirect:
"""Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets.
"""
matcher: OffsetMatcher = OffsetMatcher(currency, account)
if len(matcher.matched_pairs) == 0:
flash(s(lazy_gettext("No more offset to match automatically.")),
"success")
return redirect(or_next(
unmatched_url(currency, account)))
matcher.match()
db.session.commit()
flash(s(lazy_gettext("Matched %(matches)s offsets.",
matches=len(matcher.matched_pairs))), "success")
return redirect(or_next(unmatched_url(currency, account)))
@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

@ -39,6 +39,10 @@
.accounting-toolbar { .accounting-toolbar {
display: flex; display: flex;
} }
.accounting-toolbar-accounts {
max-height: 20rem;
overflow-y: scroll;
}
.accounting-toolbar .input-group > .input-group-text { .accounting-toolbar .input-group > .input-group-text {
padding: 0; padding: 0;
background-color: transparent; background-color: transparent;
@ -209,11 +213,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;
@ -309,6 +325,26 @@ 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 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;
}
.accounting-unmatched-table .accounting-report-table-row {
grid-template-columns: 1fr 5fr 1fr 1fr 1fr;
}
.accounting-unmatched-account-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-unmatched-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 {
@ -343,6 +379,12 @@ a.accounting-report-table-row {
margin: 0; 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;

View File

@ -364,12 +364,13 @@ class DescriptionEditorAccount extends JournalEntryAccount {
* *
* @param editor {DescriptionEditor} the description editor * @param editor {DescriptionEditor} the description editor
* @param code {string} the account code * @param code {string} the account code
* @param title {string} the account title
* @param text {string} the account text * @param text {string} the account text
* @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise * @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise
* @param button {HTMLButtonElement} the account button * @param button {HTMLButtonElement} the account button
*/ */
constructor(editor, code, text, isNeedOffset, button) { constructor(editor, code, title, text, isNeedOffset, button) {
super(code, text, isNeedOffset); super(code, title, text, isNeedOffset);
this.#element = button; this.#element = button;
this.#element.onclick = () => editor.selectAccount(this); this.#element.onclick = () => editor.selectAccount(this);
} }
@ -424,7 +425,7 @@ class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
* @param button {HTMLButtonElement} the account button * @param button {HTMLButtonElement} the account button
*/ */
constructor(editor, button) { constructor(editor, button) {
super(editor, button.dataset.code, button.dataset.text, button.classList.contains("accounting-account-is-need-offset"), button); super(editor, button.dataset.code, button.dataset.title, button.dataset.text, button.classList.contains("accounting-account-is-need-offset"), button);
} }
} }
@ -441,7 +442,7 @@ class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
* @param button {HTMLButtonElement} the account button * @param button {HTMLButtonElement} the account button
*/ */
constructor(editor, button) { constructor(editor, button) {
super(editor, "", "", false, button); super(editor, "", "", "", false, button);
this.isConfirmedAccount = true; this.isConfirmedAccount = true;
} }

View File

@ -123,7 +123,7 @@ class JournalEntryAccountSelector {
option.setShown(false); option.setShown(false);
} }
} }
if (!isAnyMatched) { if (!isAnyMatched && this.#isShowMore) {
this.#optionList.classList.add("d-none"); this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none"); this.#queryNoResult.classList.remove("d-none");
} else { } else {
@ -202,6 +202,12 @@ class JournalEntryAccountOption {
*/ */
code; code;
/**
* The account title
* @type {string}
*/
title;
/** /**
* The account text * The account text
* @type {string} * @type {string}
@ -235,6 +241,7 @@ class JournalEntryAccountOption {
constructor(selector, element) { constructor(selector, element) {
this.#element = element; this.#element = element;
this.code = element.dataset.code; this.code = element.dataset.code;
this.title = element.dataset.title;
this.text = element.dataset.text; this.text = element.dataset.text;
this.#isInUse = element.classList.contains("accounting-account-is-in-use"); this.#isInUse = element.classList.contains("accounting-account-is-in-use");
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset"); this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");

View File

@ -791,6 +791,12 @@ class JournalEntryAccount {
*/ */
code; code;
/**
* The account title
* @type {string}
*/
title;
/** /**
* The account text * The account text
* @type {string} * @type {string}
@ -807,11 +813,13 @@ class JournalEntryAccount {
* Constructs a journal entry account. * Constructs a journal entry account.
* *
* @param code {string} the account code * @param code {string} the account code
* @param title {string} the account title
* @param text {string} the account text * @param text {string} the account text
* @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise * @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise
*/ */
constructor(code, text, isNeedOffset) { constructor(code, title, text, isNeedOffset) {
this.code = code; this.code = code;
this.title = title;
this.text = text; this.text = text;
this.isNeedOffset = isNeedOffset; this.isNeedOffset = isNeedOffset;
} }
@ -822,7 +830,7 @@ class JournalEntryAccount {
* @return {JournalEntryAccount} the copy of the account * @return {JournalEntryAccount} the copy of the account
*/ */
copy() { copy() {
return new JournalEntryAccount(this.code, this.text, this.isNeedOffset); return new JournalEntryAccount(this.code, this.title, this.text, this.isNeedOffset);
} }
} }
@ -887,10 +895,16 @@ class LineItemSubForm {
#accountCode; #accountCode;
/** /**
* The text display of the account * The code part of the text display of the account
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#accountText; #accountTextCode;
/**
* The title part of the text display of the account
* @type {HTMLSpanElement}
*/
#accountTextTitle;
/** /**
* The description * The description
@ -957,7 +971,8 @@ class LineItemSubForm {
this.#error = document.getElementById(`${prefix}-error`); this.#error = document.getElementById(`${prefix}-error`);
this.#no = document.getElementById(`${prefix}-no`); this.#no = document.getElementById(`${prefix}-no`);
this.#accountCode = document.getElementById(`${prefix}-account-code`); this.#accountCode = document.getElementById(`${prefix}-account-code`);
this.#accountText = document.getElementById(`${prefix}-account-text`); this.#accountTextCode = document.getElementById(`${prefix}-account-text-code`);
this.#accountTextTitle = document.getElementById(`${prefix}-account-text-title`);
this.#description = document.getElementById(`${prefix}-description`); this.#description = document.getElementById(`${prefix}-description`);
this.#descriptionText = document.getElementById(`${prefix}-description-text`); this.#descriptionText = document.getElementById(`${prefix}-description-text`);
this.#originalLineItemId = document.getElementById(`${prefix}-original-line-item-id`); this.#originalLineItemId = document.getElementById(`${prefix}-original-line-item-id`);
@ -1024,7 +1039,7 @@ class LineItemSubForm {
* @return {JournalEntryAccount|null} the account * @return {JournalEntryAccount|null} the account
*/ */
get account() { get account() {
return this.#accountCode.value === null? null: new JournalEntryAccount(this.#accountCode.value, this.#accountCode.dataset.text, this.#accountCode.classList.contains("accounting-account-is-need-offset")); return this.#accountCode.value === null? null: new JournalEntryAccount(this.#accountCode.value, this.#accountCode.dataset.title, this.#accountCode.dataset.text, this.#accountCode.classList.contains("accounting-account-is-need-offset"));
} }
/** /**
@ -1092,13 +1107,15 @@ class LineItemSubForm {
this.#originalLineItemText.innerText = A_("Offset %(item)s", {item: editor.originalLineItemText}); this.#originalLineItemText.innerText = A_("Offset %(item)s", {item: editor.originalLineItemText});
} }
this.#accountCode.value = editor.account.code; this.#accountCode.value = editor.account.code;
this.#accountCode.dataset.title = editor.account.title;
this.#accountCode.dataset.text = editor.account.text; this.#accountCode.dataset.text = editor.account.text;
if (editor.account.isNeedOffset) { if (editor.account.isNeedOffset) {
this.#accountCode.classList.add("accounting-account-is-need-offset"); this.#accountCode.classList.add("accounting-account-is-need-offset");
} else { } else {
this.#accountCode.classList.remove("accounting-account-is-need-offset"); this.#accountCode.classList.remove("accounting-account-is-need-offset");
} }
this.#accountText.innerText = editor.account.text; this.#accountTextCode.innerText = editor.account.code
this.#accountTextTitle.innerText = editor.account.title
this.#description.value = editor.description === null? "": editor.description; this.#description.value = editor.description === null? "": editor.description;
this.#descriptionText.innerText = editor.description === null? "": editor.description; this.#descriptionText.innerText = editor.description === null? "": editor.description;
this.#amount.value = editor.amount; this.#amount.value = editor.amount;

View File

@ -276,19 +276,23 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = originalLineItem.date; this.originalLineItemDate = originalLineItem.date;
this.originalLineItemText = originalLineItem.text; this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text; this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableDescriptionAccount(false); if (this.description === null) {
if (originalLineItem.description === "") { if (originalLineItem.description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
} else { } else {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
}
this.description = originalLineItem.description === ""? null: originalLineItem.description;
this.#descriptionText.innerText = originalLineItem.description;
} }
this.description = originalLineItem.description === ""? null: originalLineItem.description; this.#setEnableAccount(false);
this.#descriptionText.innerText = originalLineItem.description;
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.account = originalLineItem.account.copy(); this.account = originalLineItem.account.copy();
this.isAccountConfirmed = false; this.isAccountConfirmed = false;
this.#accountText.innerText = this.account.text; this.#accountText.innerText = this.account.text;
this.#amountInput.value = String(originalLineItem.netBalance); if (this.#amountInput.value === "" || new Decimal(this.#amountInput.value).greaterThan(originalLineItem.netBalance)) {
this.#amountInput.value = String(originalLineItem.netBalance);
}
this.#amountInput.max = String(originalLineItem.netBalance); this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0"; this.#amountInput.min = "0";
this.#validate(); this.#validate();
@ -305,7 +309,7 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = null; this.originalLineItemDate = null;
this.originalLineItemText = null; this.originalLineItemText = null;
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true); this.#setEnableAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.account = null; this.account = null;
this.isAccountConfirmed = false; this.isAccountConfirmed = false;
@ -356,7 +360,7 @@ class JournalEntryLineItemEditor {
*/ */
saveAccount(account) { saveAccount(account) {
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.account = new JournalEntryAccount(account.code, account.text, account.isNeedOffset); this.account = new JournalEntryAccount(account.code, account.title, account.text, account.isNeedOffset);
this.isAccountConfirmed = true; this.isAccountConfirmed = true;
this.#accountText.innerText = account.text; this.#accountText.innerText = account.text;
this.#validateAccount(); this.#validateAccount();
@ -472,12 +476,13 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = null; this.originalLineItemDate = null;
this.originalLineItemText = null; this.originalLineItemText = null;
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true); this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
this.#descriptionControl.classList.remove("is-invalid"); this.#descriptionControl.classList.remove("is-invalid");
this.description = null; this.description = null;
this.#descriptionText.innerText = "" this.#descriptionText.innerText = ""
this.#descriptionError.innerText = "" this.#descriptionError.innerText = ""
this.#setEnableAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid"); this.#accountControl.classList.remove("is-invalid");
this.account = null; this.account = null;
@ -511,7 +516,7 @@ class JournalEntryLineItemEditor {
this.#originalLineItemContainer.classList.remove("d-none"); this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty"); this.#originalLineItemControl.classList.add("accounting-not-empty");
} }
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null); this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.description = lineItem.description; this.description = lineItem.description;
if (this.description === null) { if (this.description === null) {
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
@ -519,6 +524,7 @@ class JournalEntryLineItemEditor {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
} }
this.#descriptionText.innerText = this.description === null? "": this.description; this.#descriptionText.innerText = this.description === null? "": this.description;
this.#setEnableAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.account = lineItem.account; this.account = lineItem.account;
this.isAccountConfirmed = true; this.isAccountConfirmed = true;
if (this.account === null) { if (this.account === null) {
@ -547,25 +553,17 @@ class JournalEntryLineItemEditor {
} }
/** /**
* Sets the enable status of the description and account. * Sets the enable status of the account.
* *
* @param isEnabled {boolean} true to enable, or false otherwise * @param isEnabled {boolean} true to enable, or false otherwise
*/ */
#setEnableDescriptionAccount(isEnabled) { #setEnableAccount(isEnabled) {
if (isEnabled) { if (isEnabled) {
this.#descriptionControl.dataset.bsToggle = "modal";
this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#descriptionControl.classList.remove("accounting-disabled");
this.#descriptionControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal"; this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`; this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#accountControl.classList.remove("accounting-disabled"); this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable"); this.#accountControl.classList.add("accounting-clickable");
} else { } else {
this.#descriptionControl.dataset.bsToggle = "";
this.#descriptionControl.dataset.bsTarget = "";
this.#descriptionControl.classList.add("accounting-disabled");
this.#descriptionControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = ""; this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = ""; this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled"); this.#accountControl.classList.add("accounting-disabled");

View File

@ -284,7 +284,7 @@ class OriginalLineItem {
this.date = element.dataset.date; this.date = element.dataset.date;
this.#debitCredit = element.dataset.debitCredit; this.#debitCredit = element.dataset.debitCredit;
this.#currencyCode = element.dataset.currencyCode; this.#currencyCode = element.dataset.currencyCode;
this.account = new JournalEntryAccount(element.dataset.accountCode, element.dataset.accountText, false); this.account = new JournalEntryAccount(element.dataset.accountCode, element.dataset.accountTitle, element.dataset.accountText, false);
this.description = element.dataset.description; this.description = element.dataset.description;
this.bareNetBalance = new Decimal(element.dataset.netBalance); this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance; this.netBalance = this.bareNetBalance;

View File

@ -0,0 +1,37 @@
/* The Mia! Accounting Project
* timezone.js: The JavaScript for the timezone
*/
/* Copyright (c) 2024 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: 2024/6/4
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
setTimeZone();
});
/**
* Sets the time zone.
*
* @private
*/
function setTimeZone() {
document.cookie = `accounting-tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}; SameSite=Strict`;
}

View File

@ -1,7 +1,7 @@
# The Mia! Accounting 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-2024 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.
@ -17,13 +17,14 @@
"""The template filters. """The template filters.
""" """
import typing as t import datetime as dt
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any
from flask_babel import get_locale from flask_babel import get_locale
from accounting.locale import gettext from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
def format_amount(value: Decimal | None) -> str | None: def format_amount(value: Decimal | None) -> str | None:
@ -41,24 +42,24 @@ def format_amount(value: Decimal | None) -> str | None:
return "{:,}".format(whole) + str(abs(frac))[1:] return "{:,}".format(whole) + str(abs(frac))[1:]
def format_date(value: date) -> str: def format_date(value: dt.date) -> str:
"""Formats a date to be human-friendly. """Formats a date to be human-friendly.
:param value: The date. :param value: The date.
:return: The human-friendly date text. :return: The human-friendly date text.
""" """
today: date = date.today() today: dt.date = get_tz_today()
if value == today: if value == today:
return gettext("Today") return gettext("Today")
if value == today - timedelta(days=1): if value == today - dt.timedelta(days=1):
return gettext("Yesterday") return gettext("Yesterday")
if value == today + timedelta(days=1): if value == today + dt.timedelta(days=1):
return gettext("Tomorrow") return gettext("Tomorrow")
locale = str(get_locale()) locale = str(get_locale())
if locale == "zh" or locale.startswith("zh_"): if locale == "zh" or locale.startswith("zh_"):
if value == today - timedelta(days=2): if value == today - dt.timedelta(days=2):
return gettext("The day before yesterday") return gettext("The day before yesterday")
if value == today + timedelta(days=2): if value == today + dt.timedelta(days=2):
return gettext("The day after tomorrow") return gettext("The day after tomorrow")
if locale == "zh" or locale.startswith("zh_"): if locale == "zh" or locale.startswith("zh_"):
weekdays = ["", "", "", "", "", "", ""] weekdays = ["", "", "", "", "", "", ""]
@ -71,7 +72,7 @@ def format_date(value: date) -> str:
return "{}/{}({})".format(value.month, value.day, weekday) return "{}/{}({})".format(value.month, value.day, weekday)
def default(value: t.Any, default_value: t.Any = "") -> t.Any: def default(value: Any, default_value: Any = "") -> Any:
"""Returns the default value if the given value is None. """Returns the default value if the given value is None.
:param value: The value. :param value: The value.

View File

@ -21,7 +21,7 @@ from accounting.models import Currency
from accounting.utils.options import options from accounting.utils.options import options
def currency_options() -> str: def currency_options() -> list[Currency]:
"""Returns the currency options. """Returns the currency options.
:return: The currency options. :return: The currency options.

View File

@ -23,6 +23,6 @@ First written: 2023/2/1
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %} {% block back_url %}{{ url_for("accounting.account.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %} {% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}

View File

@ -90,7 +90,7 @@ First written: 2023/1/31
{% endif %} {% endif %}
<div class="accounting-card col-sm-6"> <div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title|title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_need_offset %} {% if obj.is_need_offset %}
<div> <div>

View File

@ -32,7 +32,7 @@ First written: 2023/1/30
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

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

View File

@ -26,7 +26,7 @@ First written: 2023/1/26
{% block content %} {% block content %}
<div class="mb-2 accounting-toolbar"> <div class="mb-2 accounting-toolbar">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
base.html: The application-wide base template. base.html: The application-wide base template.
Copyright (c) 2023 imacat. Copyright (c) 2023-2024 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.
@ -27,5 +27,6 @@ First written: 2023/1/27
{% block scripts %} {% block scripts %}
<script src="{{ url_for("accounting.babel_catalog") }}"></script> <script src="{{ url_for("accounting.babel_catalog") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/timezone.js") }}"></script>
{% block accounting_scripts %}{% endblock %} {% block accounting_scripts %}{% endblock %}
{% endblock %} {% endblock %}

View File

@ -23,6 +23,6 @@ First written: 2023/2/6
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %} {% block back_url %}{{ url_for("accounting.currency.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %} {% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}

View File

@ -32,7 +32,7 @@ First written: 2023/2/6
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %} {% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -37,7 +37,7 @@ First written: 2023/2/25
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list"> <ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %} {% for account in account_options %}
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> <li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-title="{{ account.title }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
{{ account }} {{ account }}
</li> </li>
{% endfor %} {% endfor %}

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28 First written: 2023/2/28
#} #}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}"> <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@ -184,7 +185,7 @@ First written: 2023/2/28
<div class="mt-3 accounting-description-editor-buttons"> <div class="mt-3 accounting-description-editor-buttons">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-account-confirmed" class="btn btn-primary mb-1 d-none" type="button"></button> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-account-confirmed" class="btn btn-primary mb-1 d-none" type="button"></button>
{% for account in description_editor.accounts %} {% for account in description_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> <button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-title="{{ account.title }}" data-text="{{ account }}">
{{ account }} {{ account }}
</button> </button>
{% endfor %} {% endfor %}

View File

@ -24,7 +24,10 @@ First written: 2023/3/14
<li class="list-group-item accounting-journal-entry-line-item"> <li class="list-group-item accounting-journal-entry-line-item">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<div class="small">{{ line_item.account }}</div> <div class="small">
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title }}
</div>
{% if line_item.description is not none %} {% if line_item.description is not none %}
<div>{{ line_item.description }}</div> <div>{{ line_item.description }}</div>
{% endif %} {% endif %}
@ -50,10 +53,10 @@ First written: 2023/3/14
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% if line_item.balance %} {% if line_item.net_balance %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div> <div>{{ A_("Net balance") }}</div>
<div>{{ line_item.balance|accounting_format_amount }}</div> <div>{{ line_item.net_balance|accounting_format_amount }}</div>
</div> </div>
{% else %} {% else %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">

View File

@ -36,7 +36,7 @@ First written: 2023/2/26
{{ A_("Edit") }} {{ A_("Edit") }}
</a> </a>
{% endif %} {% endif %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
<span class="d-none d-md-inline">{{ A_("Order") }}</span> <span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a> </a>

View File

@ -26,13 +26,16 @@ First written: 2023/2/25
{% endif %} {% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}"> <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}"> <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" class="{% if form.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}"> <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" class="{% if form.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-title="{{ form.account_title }}" data-text="{{ form.account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}"> <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}"> <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}">
<div class="accounting-line-item-content"> <div class="accounting-line-item-content">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div> <div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text" class="small">{{ form.account_text }}</div> <div class="small">
<span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text-code" class="d-none d-md-inline">{{ form.account_code.data|accounting_default }}</span>
<span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text-title">{{ form.account_title }}</span>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description-text">{{ form.description.data|accounting_default }}</div> <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description-text">{{ form.description.data|accounting_default }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not form.original_line_item_id.data %} d-none {% endif %}"> <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not form.original_line_item_id.data %} d-none {% endif %}">
{% if form.original_line_item_id.data %}{{ A_("Offset %(item)s", item=form.original_line_item_text|accounting_default) }}{% endif %} {% if form.original_line_item_id.data %}{{ A_("Offset %(item)s", item=form.original_line_item_text|accounting_default) }}{% endif %}

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
<form id="accounting-line-item-editor"> <form id="accounting-line-item-editor">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true"> <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -37,8 +37,15 @@ First written: 2023/2/25
<ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list"> <ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list">
{% for line_item in form.original_line_item_options %} {% for line_item in form.original_line_item_options %}
<li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> <li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-title="{{ line_item.account.title }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.description|accounting_default }}</div> <div>
<div class="small">
{{ line_item.journal_entry.date|accounting_format_date }}
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title }}
</div>
{{ line_item.description|accounting_default }}
</div>
<div> <div>
<span class="badge bg-primary rounded-pill"> <span class="badge bg-primary rounded-pill">
<span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span> <span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span>

View File

@ -38,7 +38,7 @@ First written: 2023/2/26
</div> </div>
{% if list|length > 1 and accounting_can_edit() %} {% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post"> <form action="{{ url_for("accounting.journal-entry.sort", date=date) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %} {% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %} {% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -23,7 +23,7 @@ First written: 2023/2/26
{% block as_trasfer %} {% block as_trasfer %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a> </a>
{% endblock %} {% endblock %}

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %} {% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -42,11 +42,11 @@ First written: 2023/3/22
<tbody> <tbody>
<tr> <tr>
<th scope="row">{{ A_("Default Currency") }}</th> <th scope="row">{{ A_("Default Currency") }}</th>
<td>{{ obj.default_currency_text }}</td> <td>{{ obj.default_currency }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th> <th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
<td>{{ obj.default_ie_account_code_text }}</td> <td>{{ obj.default_ie_account }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22 First written: 2023/3/22
#} #}
<form id="accounting-recurring-item-editor-{{ expense_income }}"> <form id="accounting-recurring-item-editor-{{ expense_income }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true"> <div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -26,7 +26,7 @@ First written: 2023/3/7
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Balance Sheet %(period)s", period=report.period.desc|title) }}{% else %}{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}
@ -46,7 +46,13 @@ First written: 2023/3/7
{% if report.has_data %} {% if report.has_data %}
<div class="accounting-sheet"> <div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3"> <div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2> <h2 class="text-center">
{% if report.currency.code == accounting_default_currency_code() %}
{{ A_("Balance Sheet %(period)s", period=report.period.desc|title) }}
{% else %}
{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}
{% endif %}
</h2>
</div> </div>
<div class="row accounting-report-table accounting-balance-sheet-table"> <div class="row accounting-report-table accounting-balance-sheet-table">

View File

@ -20,21 +20,21 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<div class="accounting-report-table-row accounting-balance-sheet-section"> <div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ section.title.title|title }}</div> <div>{{ section.title.title }}</div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for subsection in section.subsections %} {% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection"> <div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div> <div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span> <span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }} {{ subsection.title.title }}
</div> </div>
</div> </div>
{% for account in subsection.accounts %} {% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}"> <a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div> <div>
<span class="d-none d-md-inline">{{ account.account.code }}</span> <span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }} {{ account.account.title }}
</div> </div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div> <div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a> </a>

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