Compare commits

...

134 Commits

Author SHA1 Message Date
imacat 46c6767a90 Add missing semicolon in journal-entry-account-selector.js constructor 2026-04-17 06:56:53 +08:00
imacat 381298adb1 Mark internal classes as private in JavaScript modules 2026-04-17 06:11:17 +08:00
imacat bfa42000d8 Fix indentation in unapplied and unmatched account report templates 2026-04-16 20:41:08 +08:00
imacat 0d02f41417 Fix the typo in the variable name from conform to confirm in period-chooser.js 2026-04-16 20:41:06 +08:00
imacat a26f1942f8 Fix incorrect comment reference in balance-sheet.html template 2026-04-16 20:40:49 +08:00
imacat 0a7bcdd9ec Remove period-chooser script from unapplied and unmatched report templates 2026-04-16 20:40:37 +08:00
imacat b6ea944eb5 Fix the typo in the block name from as_trasfer to as_transfer in the journal entry detail templates 2026-04-15 13:35:21 +08:00
imacat be9f4f3d83 Fix the receipt and transfer journal entry details to show credit_total instead of debit_total for the credit section total 2026-04-15 13:35:21 +08:00
imacat dc42a05959 Migrate from Flask-SQLAlchemy to Flask-SQLAlchemy-Lite 2026-04-15 13:35:21 +08:00
imacat 9c6cc1f3eb Replace db.Model with DeclarativeBase from SQLAlchemy for Flask-SQLAlchemy-Lite migration 2026-04-15 13:35:21 +08:00
imacat e6d25882fc Replace Flask-SQLAlchemy helpers (db.relationship, db.ForeignKey, etc.) with plain SQLAlchemy equivalents 2026-04-15 13:35:21 +08:00
imacat 970c2e9946 Migrate from SQLAlchemy 1.x legacy Query API to 2.x style select/delete statements 2026-04-15 13:35:10 +08:00
imacat 356950e2c7 Replace typing.Type with built-in type[] for Python 3.12. 2026-04-05 23:49:16 +08:00
imacat cca3f89bf1 Replace absolute imports with relative imports 2026-04-05 23:49:16 +08:00
imacat 674b0de3b2 Fix various type hints 2026-04-05 23:49:12 +08:00
imacat 29dfc6c5a4 Fix pycodestyle styling issues 2026-04-05 07:05:20 +08:00
imacat aa3bc1ed69 Add Claude Code and Codex files to .gitignore 2026-04-04 23:40:55 +08:00
imacat 1289d7cba6 Fix type hints in the console command test case. 2026-01-11 12:02:29 +08:00
imacat d62e295dc6 Add init options to skip data initialization and remove manual cleanup in test cases. 2026-01-11 12:02:25 +08:00
imacat 693c5890ca Add db.engine.dispose() in test tearDown to fix ResourceWarning from Python 3.13. 2026-01-11 11:56:20 +08:00
imacat 3adcaa61d3 Fix httpx dependency version in pyproject.toml. 2026-01-08 21:52:11 +08:00
imacat aea9dcae79 Advanced to version 1.6.1. 2024-12-03 08:18:40 +08:00
imacat 40278eaf06 Fix test cases for compatibility with httpx 0.28.0. 2024-12-03 08:18:30 +08:00
imacat e00c14f277 Fixed the SQLite database URL for the in-memory database. 2024-07-10 05:49:29 +08:00
imacat f20c462685 Advanced to version 1.6.0. 2024-06-04 08:29:26 +08:00
imacat 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
imacat 6ee3ee76ea Updated optional dependencies in pyproject.toml. 2024-06-04 08:28:58 +08:00
imacat 2bfcc8b889 Updated the dependencies in pyproject.toml. 2024-06-04 08:28:15 +08:00
imacat 99564c02d0 Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test site. 2024-04-21 22:41:46 +02:00
imacat 25d9904180 Applied the new type parameter syntax to the generic classes for Python 3.12. 2024-03-03 07:39:37 +08:00
imacat 1cf83adf87 Applied the "type" statement to type aliases for Python 3.12. 2024-03-03 07:39:20 +08:00
imacat 8e3d1f11b5 Updated Python version to 3.12. 2024-03-03 07:38:59 +08:00
imacat 0ab14aa34d Updated the copyright year in README.rst. 2024-03-03 07:38:32 +08:00
imacat e0ed81ad1f Advanced to version 1.5.11. 2023-12-16 21:52:15 +08:00
imacat ece7481e9e Refined to enable the selection of the 3351-001 Accumulated Profit or Loss account. 2023-12-16 21:51:14 +08:00
imacat 50d4526e0b Advanced to version 1.5.10. 2023-11-28 08:27:31 +08:00
imacat 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
imacat dcc9626b23 Fixed the release date of version 1.5.9 in the change log. 2023-11-28 08:17:25 +08:00
imacat 79eb077129 Advanced to version 1.5.9. 2023-11-28 08:10:00 +08:00
imacat 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
imacat 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
imacat 937908717b Advanced to version 1.5.8. 2023-10-24 05:00:53 +05:30
imacat 0104fa4c21 Fixed an icon in the detail of the cash receipt journal entry. 2023-10-24 04:43:11 +05:30
imacat 14365ca255 Advanced to version 1.5.7. 2023-07-29 13:24:52 +08:00
imacat 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
imacat 9147744ff7 Renamed the test_init test to test_init_db in the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
imacat 1a212a5330 Updated the documentation of the test_init test of the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
imacat 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
imacat 545f49043b Updated the Sphinx documentation. 2023-07-29 13:07:08 +08:00
imacat cac0d66ca1 Updated the translation. 2023-07-29 13:07:08 +08:00
imacat 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
imacat 9ae8c1bce9 Updated the translation. 2023-07-29 10:11:45 +08:00
imacat 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
imacat 40a8080751 Removed unused imports from the test site. 2023-07-29 10:11:45 +08:00
imacat 736a4086ee Removed an unused import from testlib_journal_entry.py. 2023-07-29 10:11:45 +08:00
imacat 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
imacat 0ae00bce79 Changed the properties of the test cases from public to private. 2023-07-29 10:11:45 +08:00
imacat 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
imacat 501c4b1d22 Added missing documentation to the global variables, class properties, and object properties. 2023-07-29 10:11:44 +08:00
imacat 64b9c8c11f Removed an excess property declaration in the populate_obj method of the JournalEntryForm form. 2023-07-29 10:03:46 +08:00
imacat 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
imacat 30fd9c2164 Fixed the documentation of the "is_default" property of the Period utility. 2023-06-05 22:43:35 +08:00
imacat 7cb01b4cee Revised the documentation of the columns of the data models. 2023-06-05 16:55:25 +08:00
imacat 9a4e04c41f Renamed the HTML ID "collapsible-navbar" to "accounting-collapsible-navbar" in the test site. 2023-06-03 11:12:28 +08:00
imacat a9c4fa9de0 Advanced to version 1.5.6. 2023-05-23 09:32:48 +08:00
imacat 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
imacat 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
imacat 352867797d Advanced to version 1.5.5. 2023-05-23 09:30:33 +08:00
imacat 09a344d749 Removed excess spaces from the test_change_date test of the JournalEntryReorderTestCase test case. 2023-05-23 09:30:33 +08:00
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat abe90d3483 Advanced to version 1.5.4. 2023-05-18 00:06:16 +08:00
imacat 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
imacat 74e414badf Removed unnecessary f-strings from the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-17 23:54:52 +08:00
imacat 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
imacat 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
imacat 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
imacat a691cfd2da Applied the or_next utility to the set local route of the test site. 2023-05-17 19:57:23 +08:00
imacat 482a0faa23 Added safeguard to the next URI utilities from invalid or insecure next URI. 2023-05-17 16:26:35 +08:00
imacat 0ecf7b6617 Revised the documentation of the "accounting.utils.cast" module. 2023-05-17 15:33:42 +08:00
imacat 4408bbfc82 Updated the JavaScript library versions, and added decimal.js-light to the documentation. 2023-05-06 23:59:06 +08:00
imacat 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
imacat 0b1dd4f4fc Advanced to version 1.5.3. 2023-04-30 15:07:46 +08:00
imacat 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
imacat 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
imacat 2969e83afe Advanced to version 1.5.2. 2023-04-30 06:43:18 +08:00
imacat a732656746 Revised the coding style in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:43 +08:00
imacat 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
imacat f29cb00aec Advanced to version 1.5.1. 2023-04-30 05:53:37 +08:00
imacat 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
imacat 8c899776f2 Corrected the filename in the csv method of the AccountsWithUnmatchedOffsets report class. 2023-04-30 05:35:13 +08:00
imacat f9aa226bf9 Removed an unnecessary f-string from the csv method of the AccountsWithUnappliedOriginalLineItems report class. 2023-04-30 05:34:34 +08:00
imacat 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
imacat 9ae8d587d8 Removed the unused "random_pk" annotated type alias. 2023-04-29 04:16:11 +08:00
imacat 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
imacat 0bc9947234 Revised the documentation of the new_id function. 2023-04-26 20:36:09 +08:00
imacat 8c58a9083a Added type hint subscription for the cls parameter of the new_id function. 2023-04-26 18:31:13 +08:00
imacat f45663754c Fixed the documentation of the "accounting.utils.random_id" module. 2023-04-26 18:30:18 +08:00
imacat 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
imacat 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
imacat 25bfcf4aa4 Fixed the documentation of the balance pseudo property of the JournalEntryLineItem data model. 2023-04-26 13:40:48 +08:00
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 8d412ec00a Renamed the "journal_entry_date" parameter to "date" in the show_journal_entry_order route. 2023-04-26 13:32:42 +08:00
imacat 2986c518ce Renamed the "journal_entry_date" parameter to "date" in the sort_journal_entries route. 2023-04-26 13:32:30 +08:00
imacat f1351243a6 Renamed the "journal_entry_date" parameter to "date" in the constructor of the JournalEntryReorderForm form. 2023-04-26 13:29:55 +08:00
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat 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
imacat c11ae23885 Removed an unused import from the "accounting.utils.cast" module. 2023-04-26 13:15:18 +08:00
imacat 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
imacat 167990fc4c Renamed the random_id type alias to random_pk. 2023-04-24 20:22:33 +08:00
imacat 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
imacat f6567794e0 Added the documentation to the authentication blueprint of the test site. 2023-04-24 14:00:32 +08:00
imacat 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
imacat 6d780e9296 Revised the title of the change log. 2023-04-23 22:19:01 +08:00
154 changed files with 3703 additions and 2631 deletions
+2
View File
@@ -25,6 +25,8 @@ venv
.DS_Store
.idea
.claude
.codex
instance
flask_session
+1
View File
@@ -38,3 +38,4 @@ python:
install:
- method: pip
path: .
- requirements: docs/requirements.txt
+1 -1
View File
@@ -59,7 +59,7 @@ Refer to the `change log`_.
Copyright
=========
Copyright (c) 2023 imacat.
Copyright (c) 2023-2024 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1
View File
@@ -0,0 +1 @@
sphinx_rtd_theme
+8
View File
@@ -100,6 +100,14 @@ accounting.utils.strip\_text module
:undoc-members:
:show-inheritance:
accounting.utils.title\_case module
-----------------------------------
.. automodule:: accounting.utils.title_case
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------
+155 -2
View File
@@ -1,5 +1,158 @@
Changes
=======
Change Log
==========
Version 1.6.1
--------------
Released 2024/12/3
Fix test cases for compatibility with httpx 0.28.0.
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
+8 -3
View File
@@ -13,7 +13,7 @@ The following is an example configuration for *Mia! Accounting*.
from flask import Response, redirect
from .auth import current_user()
from .modules import User
from .modules import Base, User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
@@ -37,7 +37,11 @@ The following is an example configuration for *Mia! Accounting*.
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
def base(self) -> type[DeclarativeBase]:
return Base
@property
def cls(self) -> type[User]:
return User
@property
@@ -49,7 +53,8 @@ The following is an example configuration for *Mia! Accounting*.
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
return db.session.scalar(
sa.select(User).where(User.username == username))
def get_pk(self, user: User) -> int:
return user.id
+5 -4
View File
@@ -50,9 +50,9 @@ 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
* FontAwesome_ 6.4.0 or above
* `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
* `Tempus-Dominus`_ 6.7.7 or above
Configuration
@@ -114,6 +114,7 @@ Check your Flask application and see how it works.
.. _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
.. _decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
.. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
+6 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2023 imacat.
# Copyright (c) 2022-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@ name = "mia-accounting"
dynamic = ["version"]
description = "A Flask accounting module."
readme = "README.rst"
requires-python = ">=3.11"
requires-python = ">=3.12"
authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
]
@@ -33,18 +33,17 @@ classifiers = [
"Topic :: Office/Business :: Financial :: Accounting",
]
dependencies = [
"flask",
"Flask",
"SQLAlchemy >= 2",
"Flask-SQLAlchemy",
"Flask-SQLAlchemy-Lite",
"Flask-WTF",
"Flask-Babel >= 3",
"Flask-Babel-JS",
]
[project.optional-dependencies]
test = [
"unittest",
"httpx",
devel = [
"httpx >= 0.28.0",
"OpenCC",
]
+6 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,11 +20,11 @@
from pathlib import Path
from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy_lite import SQLAlchemy
from accounting.utils.user import UserUtilityInterface
from .utils.user import UserUtilityInterface
VERSION: str = "1.5.0"
VERSION: str = "1.6.1"
"""The package version."""
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
@@ -63,8 +63,9 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code")
from .commands import init_db_command
from .commands import init_db_command, titleize_command
app.cli.add_command(init_db_command)
app.cli.add_command(titleize_command)
from . import locale
locale.init_app(app, bp)
+14 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,17 +17,17 @@
"""The console commands for the account management.
"""
import typing as t
from secrets import randbelow
from typing import Any
import click
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk
import sqlalchemy as sa
AccountData = tuple[int, str, int, str, str, str, bool]
from .. import db
from ..models import BaseAccount, Account, AccountL10n
from ..utils.user import get_user_pk
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,
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
@@ -36,13 +36,14 @@ def init_accounts_command(username: str) -> None:
"""Initializes the accounts."""
creator_pk: int = get_user_pk(username)
bases: list[BaseAccount] = BaseAccount.query\
.filter(db.func.length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all()
bases: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount).where(sa.func.length(BaseAccount.code) == 4)
.order_by(BaseAccount.code)).unique().all()
if len(bases) == 0:
raise click.Abort
existing: list[Account] = Account.query.all()
existing: list[Account] = \
db.session.scalars(sa.select(Account)).unique().all()
existing_base_code: set[str] = {x.base_code for x in existing}
bases_to_add: list[BaseAccount] = [x for x in bases
@@ -63,8 +64,8 @@ def init_accounts_command(username: str) -> None:
existing_id.add(new_id)
return new_id
data: list[dict[str, t.Any]] = []
l10n_data: list[dict[str, t.Any]] = []
data: list[dict[str, Any]] = []
l10n_data: list[dict[str, Any]] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
account_id: int = get_new_id()
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from ..models import Account
class AccountConverter(BaseConverter):
+20 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,12 +23,12 @@ from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import BaseAccount, Account
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
from .. import db
from ..locale import lazy_gettext
from ..models import BaseAccount, Account
from ..utils.random_id import new_id
from ..utils.strip_text import strip_text
from ..utils.user import get_current_user_pk
class BaseAccountExists:
@@ -97,8 +97,9 @@ class AccountForm(FlaskForm):
if obj.base_code is not None:
sort_accounts_in(obj.base_code, obj.id)
sort_accounts_in(self.base_code.data, obj.id)
count: int = Account.query\
.filter(Account.base_code == self.base_code.data).count()
count: int = db.session.scalar(
sa.select(sa.func.count(Account.id))
.where(Account.base_code == self.base_code.data))
obj.base_code = self.base_code.data
obj.no = count + 1
obj.title = self.title.data
@@ -137,9 +138,10 @@ class AccountForm(FlaskForm):
:return: The selectable base accounts.
"""
return BaseAccount.query\
.filter(sa.func.char_length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all()
return db.session.scalars(
sa.select(BaseAccount)
.where(sa.func.char_length(BaseAccount.code) == 4)
.order_by(BaseAccount.code)).unique()
def sort_accounts_in(base_code: str, exclude: int) -> None:
@@ -150,10 +152,10 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
:param exclude: The account ID to exclude.
:return: None.
"""
accounts: list[Account] = Account.query\
.filter(Account.base_code == base_code,
Account.id != exclude)\
.order_by(Account.no).all()
accounts: list[Account] = db.session.scalars(
sa.select(Account)
.where(Account.base_code == base_code, Account.id != exclude)
.order_by(Account.no)).unique().all()
for i in range(len(accounts)):
if accounts[i].no != i + 1:
accounts[i].no = i + 1
@@ -168,7 +170,9 @@ class AccountReorderForm:
:param base: The base account.
"""
self.base: BaseAccount = base
"""The base account."""
self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None:
"""Saves the order of the account.
+18 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,9 +20,10 @@
import sqlalchemy as sa
from flask import request
from accounting.locale import gettext
from accounting.models import Account, AccountL10n
from accounting.utils.query import parse_query_keywords
from .. import db
from ..locale import gettext
from ..models import Account, AccountL10n
from ..utils.query import parse_query_keywords
def get_account_query() -> list[Account]:
@@ -32,17 +33,20 @@ def get_account_query() -> list[Account]:
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Account.query.order_by(Account.base_code, Account.no).all()
code: sa.BinaryExpression = Account.base_code + "-" \
return db.session.scalars(
sa.select(Account)
.order_by(Account.base_code, Account.no)).unique().all()
code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\
.filter(AccountL10n.title.icontains(k)).all()
l10n_matches: set[str] = {x.account_id for x in l10n}
sub_conditions: list[sa.BinaryExpression] \
l10n: list[AccountL10n] = db.session.scalars(
sa.select(AccountL10n)
.where(AccountL10n.title.icontains(k))).all()
l10n_matches: set[int] = {x.account_id for x in l10n}
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k),
Account.title_l10n.icontains(k),
code.contains(k),
@@ -51,5 +55,6 @@ def get_account_query() -> list[Account]:
sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\
.order_by(Account.base_code, Account.no).all()
return db.session.scalars(
sa.select(Account).where(*conditions)
.order_by(Account.base_code, Account.no)).unique().all()
+17 -17
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,20 +21,20 @@ from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, flash, \
url_for, request
url_for, request, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .queries import get_account_query
from .. import db
from ..locale import lazy_gettext
from ..models import Account, BaseAccount
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import can_view, has_permission, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management."""
@@ -47,8 +47,8 @@ def list_accounts() -> str:
:return: The account list.
"""
accounts: list[BaseAccount] = get_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
accounts: list[Account] = get_account_query()
pagination: Pagination = Pagination[Account](accounts)
return render_template("accounting/account/list.html",
list=pagination.list, pagination=pagination)
@@ -72,7 +72,7 @@ def show_add_account_form() -> str:
@bp.post("store", endpoint="store")
@has_permission(can_edit)
def add_account() -> redirect:
def add_account() -> Response:
"""Adds an account.
:return: The redirection to the account detail on success, or the account
@@ -123,7 +123,7 @@ def show_account_edit_form(account: Account) -> str:
@bp.post("<account:account>/update", endpoint="update")
@has_permission(can_edit)
def update_account(account: Account) -> redirect:
def update_account(account: Account) -> Response:
"""Updates an account.
:param account: The account.
@@ -150,7 +150,7 @@ def update_account(account: Account) -> redirect:
@bp.post("<account:account>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_account(account: Account) -> redirect:
def delete_account(account: Account) -> Response:
"""Deletes an account.
:param account: The account.
@@ -180,7 +180,7 @@ def show_account_order(base: BaseAccount) -> str:
@bp.post("bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect:
def sort_accounts(base: BaseAccount) -> Response:
"""Reorders the accounts under a base account.
:param base: The base account.
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,20 +21,20 @@ import csv
import sqlalchemy as sa
from accounting import data_dir
from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n
from .. import db, data_dir
from ..models import BaseAccount, BaseAccountL10n
from ..utils.title_case import title_case
def init_base_accounts_command() -> None:
"""Initializes the base accounts."""
if BaseAccount.query.first() is not None:
if db.session.scalar(sa.select(BaseAccount)) is not None:
return
with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
account_data: list[dict[str, str]] = [{"code": x["code"],
"title_l10n": x["title"]}
account_data: list[dict[str, str]] = \
[{"code": x["code"], "title_l10n": title_case(x["title"])}
for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,8 @@
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import BaseAccount
from .. import db
from ..models import BaseAccount
class BaseAccountConverter(BaseConverter):
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,9 @@
import sqlalchemy as sa
from flask import request
from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.query import parse_query_keywords
from .. import db
from ..models import BaseAccount, BaseAccountL10n
from ..utils.query import parse_query_keywords
def get_base_account_query() -> list[BaseAccount]:
@@ -31,14 +32,17 @@ def get_base_account_query() -> list[BaseAccount]:
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return BaseAccount.query.order_by(BaseAccount.code).all()
conditions: list[sa.BinaryExpression] = []
return db.session.scalars(
sa.select(BaseAccount).order_by(BaseAccount.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
.filter(BaseAccountL10n.title.icontains(k)).all()
l10n: list[BaseAccountL10n] = db.session.scalars(
sa.select(BaseAccountL10n)
.where((BaseAccountL10n.title.icontains(k)))).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.icontains(k),
BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\
.order_by(BaseAccount.code).all()
return db.session.scalars(
sa.select(BaseAccount).where(*conditions)
.order_by(BaseAccount.code)).unique().all()
+4 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,10 +19,10 @@
"""
from flask import Blueprint, render_template
from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query
from ..models import BaseAccount
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view
bp: Blueprint = Blueprint("base-account", __name__)
"""The view blueprint for the base account management."""
@@ -50,4 +50,3 @@ def show_account_detail(account: BaseAccount) -> str:
:return: The detail.
"""
return render_template("accounting/base-account/detail.html", obj=account)
+49 -8
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,13 +20,16 @@
import os
import click
import sqlalchemy as sa
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.utils.user import has_user
from . import db
from .account import init_accounts_command
from .base_account import init_base_accounts_command
from .currency import init_currencies_command
from .models import BaseAccount, Account
from .utils.title_case import title_case
from .utils.user import base_cls, has_user, get_user_pk
def __validate_username(ctx: click.core.Context, param: click.core.Option,
@@ -51,12 +54,50 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@click.option("--skip-accounts", is_flag=True, default=False,
help="Skip initializing accounts.")
@click.option("--skip-currencies", is_flag=True, default=False,
help="Skip initializing currencies.")
@with_appcontext
def init_db_command(username: str) -> None:
def init_db_command(username: str, skip_accounts: bool,
skip_currencies: bool) -> None:
"""Initializes the accounting database."""
db.create_all()
base_cls.metadata.create_all(db.engine)
init_base_accounts_command()
if not skip_accounts:
init_accounts_command(username)
print("OK 1")
if not skip_currencies:
init_currencies_command(username)
print("OK 2")
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 db.session.scalars(sa.select(BaseAccount)).unique():
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 db.session.scalars(sa.select(Account)).unique():
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.")
+8 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,18 +18,19 @@
"""
import csv
import typing as t
from typing import Any
import sqlalchemy as sa
from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import get_user_pk
from .. import db, data_dir
from ..models import Currency, CurrencyL10n
from ..utils.user import get_user_pk
def init_currencies_command(username: str) -> None:
"""Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()}
existing_codes: set[str] = \
{x.code for x in db.session.scalars(sa.select(Currency)).unique()}
with open(data_dir / "currencies.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
@@ -39,7 +40,7 @@ def init_currencies_command(username: str) -> None:
return
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"],
"created_by_id": creator_pk,
"updated_by_id": creator_pk}
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,8 @@
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import Currency
from .. import db
from ..models import Currency
class CurrencyConverter(BaseConverter):
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,11 +21,11 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
from .. import db
from ..locale import lazy_gettext
from ..models import Currency
from ..utils.strip_text import strip_text
from ..utils.user import get_current_user_pk
class CodeUnique:
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,9 @@
import sqlalchemy as sa
from flask import request
from accounting.models import Currency, CurrencyL10n
from accounting.utils.query import parse_query_keywords
from .. import db
from ..models import Currency, CurrencyL10n
from ..utils.query import parse_query_keywords
def get_currency_query() -> list[Currency]:
@@ -31,14 +32,17 @@ def get_currency_query() -> list[Currency]:
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Currency.query.order_by(Currency.code).all()
conditions: list[sa.BinaryExpression] = []
return db.session.scalars(
sa.select(Currency).order_by(Currency.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.icontains(k)).all()
l10n: list[CurrencyL10n] = db.session.scalars(
sa.select(CurrencyL10n)
.where(CurrencyL10n.name.icontains(k))).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\
.order_by(Currency.code).all()
return db.session.scalars(
sa.select(Currency).where(*conditions)
.order_by(Currency.code)).unique().all()
+14 -14
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,20 +21,20 @@ from urllib.parse import urlencode, parse_qsl
import sqlalchemy as sa
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm
from .queries import get_currency_query
from .. import db
from ..locale import lazy_gettext
from ..models import Currency
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management."""
@@ -74,7 +74,7 @@ def show_add_currency_form() -> str:
@bp.post("store", endpoint="store")
@has_permission(can_edit)
def add_currency() -> redirect:
def add_currency() -> Response:
"""Adds a currency.
:return: The redirection to the currency detail on success, or the currency
@@ -125,7 +125,7 @@ def show_currency_edit_form(currency: Currency) -> str:
@bp.post("<currency:currency>/update", endpoint="update")
@has_permission(can_edit)
def update_currency(currency: Currency) -> redirect:
def update_currency(currency: Currency) -> Response:
"""Updates a currency.
:param currency: The currency.
@@ -153,7 +153,7 @@ def update_currency(currency: Currency) -> redirect:
@bp.post("<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect:
def delete_currency(currency: Currency) -> Response:
"""Deletes a currency.
:param currency: The currency.
+6 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,10 +24,9 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
from . import db
from .locale import lazy_gettext
from .models import Currency, Account
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
@@ -65,12 +64,12 @@ class IsDebitAccount:
:param message: The error message.
"""
self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)
@@ -85,12 +84,12 @@ class IsCreditAccount:
:param message: The error message.
"""
self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)
+8 -8
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,14 +17,14 @@
"""The path converters for the journal entry management.
"""
from datetime import date
import datetime as dt
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType
from .. import db
from ..models import JournalEntry
from ..utils.journal_entry_types import JournalEntryType
class JournalEntryConverter(BaseConverter):
@@ -82,18 +82,18 @@ class DateConverter(BaseConverter):
"""The date converter to convert the ISO date from and to the
corresponding date in the routes."""
def to_python(self, value: str) -> date:
def to_python(self, value: str) -> dt.date:
"""Converts an ISO date to a date.
:param value: The ISO date.
:return: The corresponding date.
"""
try:
return date.fromisoformat(value)
return dt.date.fromisoformat(value)
except ValueError:
abort(404)
def to_url(self, value: date) -> str:
def to_url(self, value: dt.date) -> str:
"""Converts a date to its ISO date.
:param value: The date.
+16 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -26,13 +26,13 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField
from wtforms.validators import DataRequired
from accounting import db
from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
from ... import db
from ...forms import CurrencyExists
from ...locale import lazy_gettext
from ...models import JournalEntryLineItem
from ...utils.offset_alias import offset_alias
from ...utils.strip_text import strip_text
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
@@ -55,7 +55,7 @@ class SameCurrencyAsOriginalLineItems:
return
original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntryLineItem.currency_code)
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
.where(JournalEntryLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_line_item_currency_codes:
if field.data != currency_code:
raise ValidationError(lazy_gettext(
@@ -72,17 +72,17 @@ class KeepCurrencyWhenHavingOffset:
if field.data is None:
return
offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\
original_line_items: list[JournalEntryLineItem] = db.session.scalars(
sa.select(JournalEntryLineItem)
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(JournalEntryLineItem.id
isouter=True)
.where(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items
if x.id.data is not None}))\
if x.id.data is not None}))
.group_by(JournalEntryLineItem.id,
JournalEntryLineItem.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all()
JournalEntryLineItem.currency_code)
.having(sa.func.count(offset.c.id) > 0)).unique().all()
for original_line_item in original_line_items:
if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext(
@@ -152,7 +152,7 @@ class CurrencyForm(FlaskForm):
line_item_id: set[int] = {x.id.data for x in line_item_forms
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
.filter(JournalEntryLineItem.original_line_item_id
.where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select) > 0
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,7 +18,6 @@
"""
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
@@ -28,21 +27,20 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
CashDisbursementCurrencyForm, TransferCurrencyForm
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
from .reorder import sort_journal_entries_in
from ..utils.account_option import AccountOption
from ..utils.description_editor import DescriptionEditor
from ..utils.original_line_items import get_selectable_original_line_items
from ... import db
from ...locale import lazy_gettext
from ...models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from ...utils.random_id import new_id
from ...utils.strip_text import strip_multiline_text
from ...utils.user import get_current_user_pk
DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date."))
@@ -123,7 +121,7 @@ class JournalEntryForm(FlaskForm):
super().__init__(*args, **kwargs)
self.is_modified: bool = False
"""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
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
@@ -151,19 +149,19 @@ class JournalEntryForm(FlaskForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
self.date: DateField
self.__set_date(obj, self.date.data)
obj.note = self.note.data
collector_cls: t.Type[LineItemCollector] = self.collector
collector_cls: type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj)
collector.collect()
to_delete: set[int] = {x.id for x in obj.line_items
if x.id not in collector.to_keep}
if len(to_delete) > 0:
JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
db.session.execute(
sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.id.in_(to_delete)))
self.is_modified = True
if is_new or db.session.is_modified(obj):
@@ -198,7 +196,7 @@ class JournalEntryForm(FlaskForm):
if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(JournalEntry.no))
.filter(JournalEntry.date == new_date))
.where(JournalEntry.date == new_date))
if db_min_no is None:
obj.date = new_date
obj.no = 1
@@ -208,8 +206,9 @@ class JournalEntryForm(FlaskForm):
sort_journal_entries_in(new_date)
else:
sort_journal_entries_in(new_date, obj.id)
count: int = JournalEntry.query\
.filter(JournalEntry.date == new_date).count()
count: int = db.session.scalar(
sa.select(sa.func.count(JournalEntry.id))
.where(JournalEntry.date == new_date))
obj.date = new_date
obj.no = count + 1
@@ -224,7 +223,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(JournalEntryLineItem.is_debit)
.where(JournalEntryLineItem.is_debit)
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
@@ -241,7 +240,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(sa.not_(JournalEntryLineItem.is_debit))
.where(sa.not_(JournalEntryLineItem.is_debit))
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
@@ -291,7 +290,7 @@ class JournalEntryForm(FlaskForm):
return None
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
.where(JournalEntryLineItem.id.in_(original_line_item_id))
return db.session.scalar(select)
@property
@@ -304,16 +303,12 @@ class JournalEntryForm(FlaskForm):
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.original_line_item_id
.where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select)
T = t.TypeVar("T", bound=JournalEntryForm)
"""A journal entry form variant."""
class LineItemCollector(t.Generic[T], ABC):
class LineItemCollector[T: JournalEntryForm](ABC):
"""The line item collector."""
def __init__(self, form: T, obj: JournalEntry):
+19 -18
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,7 +17,7 @@
"""The line item sub-forms for the journal entry management.
"""
from datetime import date
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
@@ -27,15 +27,15 @@ from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import Optional
from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
from ... import db
from ...forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
from ...locale import lazy_gettext
from ...models import Account, JournalEntry, JournalEntryLineItem
from ...template_filters import format_amount
from ...utils.random_id import new_id
from ...utils.strip_text import strip_text
from ...utils.user import get_current_user_pk
class OriginalLineItemExists:
@@ -202,7 +202,7 @@ class NotExceedingOriginalLineItemNetBalance:
else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(JournalEntryLineItem.original_line_item_id
.where(JournalEntryLineItem.original_line_item_id
== original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None:
@@ -231,7 +231,7 @@ class NotLessThanOffsetTotal:
(JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\
.filter(JournalEntryLineItem.original_line_item_id == form.id.data)
.where(JournalEntryLineItem.original_line_item_id == form.id.data)
offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext(
@@ -307,7 +307,7 @@ class LineItemForm(FlaskForm):
return getattr(self, "____original_line_item")
@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.
:return: The text representation of the original line item.
@@ -353,13 +353,14 @@ class LineItemForm(FlaskForm):
def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None:
return []
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\
return db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(JournalEntryLineItem.original_line_item_id
== self.id.data)
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account)).all()
selectinload(JournalEntryLineItem.account))).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
+18 -17
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,31 +17,29 @@
"""The reorder forms for the journal entry management.
"""
from datetime import date
import datetime as dt
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import JournalEntry
from ... import db
from ...models import JournalEntry
def sort_journal_entries_in(journal_entry_date: date,
exclude: int | None = None) -> None:
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
"""Sorts the journal entries under a date after changing the date or
deleting a journal entry.
:param journal_entry_date: The date of the journal entry.
:param date: The date of the journal entry.
:param exclude: The journal entry ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.date == journal_entry_date]
conditions: list[sa.ColumnElement[bool]] = [JournalEntry.date == date]
if exclude is not None:
conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(*conditions)\
.order_by(JournalEntry.no).all()
journal_entries: list[JournalEntry] = db.session.scalars(
sa.select(JournalEntry).where(*conditions)
.order_by(JournalEntry.no)).all()
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
@@ -50,21 +48,24 @@ def sort_journal_entries_in(journal_entry_date: date,
class JournalEntryReorderForm:
"""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.
: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
"""Whether the order is modified."""
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.date == self.date).all()
journal_entries: list[JournalEntry] = db.session.scalars(
sa.select(JournalEntry)
.where(JournalEntry.date == self.date)).all()
# Collects the specified order.
orders: dict[JournalEntry, int] = {}
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,7 +17,7 @@
"""The account option for the journal entry management.
"""
from accounting.models import Account
from ...models import Account
class AccountOption:
@@ -28,7 +28,7 @@ class AccountOption:
:param account: The account.
"""
self.id: str = account.id
self.id: int = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,13 +18,13 @@
"""
import re
import typing as t
from typing import Literal
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.options import options, Recurring
from ... import db
from ...models import Account, JournalEntryLineItem
from ...utils.options import options, Recurring
class DescriptionAccount:
@@ -124,12 +124,12 @@ class DescriptionTag:
class DescriptionType:
"""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.
: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."""
self.__tag_dict: dict[str, DescriptionTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
@@ -166,8 +166,11 @@ class DescriptionRecurring:
:param account: The account.
"""
self.name: str = name
"""The name."""
self.account: DescriptionAccount = DescriptionAccount(account, 0)
"""The account."""
self.description_template: str = description_template
"""The description template."""
@property
def account_codes(self) -> list[str]:
@@ -181,12 +184,12 @@ class DescriptionRecurring:
class DescriptionDebitCredit:
"""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.
: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."""
self.general: DescriptionType = DescriptionType("general")
"""The general tags."""
@@ -194,14 +197,14 @@ class DescriptionDebitCredit:
"""The travel tags."""
self.bus: DescriptionType = DescriptionType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
self.__type_dict: dict[Literal["general", "travel", "bus"],
DescriptionType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
self.recurring: list[DescriptionRecurring] = []
"""The recurring transactions."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
def add_tag(self, tag_type: Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
"""Adds a tag.
@@ -269,16 +272,18 @@ class DescriptionEditor:
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntryLineItem.description.is_not(None),
.where(JournalEntryLineItem.description.is_not(None),
JournalEntryLineItem.description.like("_%—_%"),
JournalEntryLineItem.original_line_item_id.is_(None))\
.group_by(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()}
debit_credit_dict: dict[t.Literal["debit", "credit"],
= {x.id: x for x in db.session.scalars(
sa.select(Account)
.where(Account.id.in_({x.account_id for x in result})))
.unique()}
debit_credit_dict: dict[Literal["debit", "credit"],
DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result:
@@ -312,20 +317,21 @@ class DescriptionEditor:
if len(codes) == 0:
return {}
def get_condition(code0: str) -> sa.BinaryExpression:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None,\
def get_condition(code0: str) -> sa.ColumnElement[bool]:
m: re.Match[str] | None = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None, \
f"Malformed account code \"{code0}\" for regular transactions."
return sa.and_(Account.base_code == m.group(1),
Account.no == int(m.group(2)))
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [get_condition(x) for x in codes]
accounts: dict[str, Account] \
= {x.code: x for x in
Account.query.filter(sa.or_(*conditions)).all()}
db.session.scalars(
sa.select(Account).where(sa.or_(*conditions))).unique()}
for code in codes:
assert code in accounts,\
assert code in accounts, \
f"Unknown account \"{code}\" for regular transactions."
return accounts
+12 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,19 +17,17 @@
"""The operators for different journal entry types.
"""
import typing as t
from abc import ABC, abstractmethod
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.journal_entry.forms import JournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm
from accounting.journal_entry.forms.line_item import LineItemForm
from ..forms import JournalEntryForm, CashReceiptJournalEntryForm, \
CashDisbursementJournalEntryForm, TransferJournalEntryForm
from ..forms.line_item import LineItemForm
from ...models import JournalEntry
from ...template_globals import default_currency_code
from ...utils.journal_entry_types import JournalEntryType
class JournalEntryOperator(ABC):
@@ -39,7 +37,7 @@ class JournalEntryOperator(ABC):
@property
@abstractmethod
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -100,7 +98,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -170,7 +168,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -243,7 +241,7 @@ class TransferJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -334,3 +332,4 @@ def get_journal_entry_op(journal_entry: JournalEntry,
key=lambda x: x.CHECK_ORDER):
if journal_entry_type.is_my_type(journal_entry):
return journal_entry_type
assert False
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ from decimal import Decimal
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from ... import db
from ...models import Account, JournalEntry, JournalEntryLineItem
from ...utils.offset_alias import offset_alias
def get_selectable_original_line_items(
@@ -46,8 +46,8 @@ def get_selectable_original_line_items(
(offset.c.id.in_(line_item_id_on_form), 0),
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = [Account.is_need_offset]
sub_conditions: list[sa.ColumnElement[bool]] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)))
@@ -61,20 +61,21 @@ def get_selectable_original_line_items(
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(*conditions)\
.where(*conditions)\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
.join(JournalEntry)\
for x in db.session.execute(select_net_balances)}
line_items: list[JournalEntryLineItem] = db.session.scalars(
sa.select(JournalEntryLineItem)
.where(JournalEntryLineItem.id.in_({x for x in net_balances}))
.join(JournalEntry)
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry))).all()
line_items.reverse()
for line_item in line_items:
line_item.net_balance = line_item.amount \
+28 -27
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,28 +17,29 @@
"""The views for the journal entry management.
"""
from datetime import date
import datetime as dt
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
get_journal_entry_op
from .. import db
from ..locale import lazy_gettext
from ..models import JournalEntry
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.journal_entry_types import JournalEntryType
from ..utils.next_uri import inherit_next, or_next
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.timezone import get_tz_today
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("journal-entry", __name__)
"""The view blueprint for the journal entry management."""
@@ -67,13 +68,13 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
form.validate()
else:
form = journal_entry_op.form()
form.date.data = date.today()
form.date.data = get_tz_today()
return journal_entry_op.render_create_template(form)
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
def add_journal_entry(journal_entry_type: JournalEntryType) -> Response:
"""Adds a journal entry.
:param journal_entry_type: The journal entry type.
@@ -135,7 +136,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
def update_journal_entry(journal_entry: JournalEntry) -> Response:
"""Updates a journal entry.
:param journal_entry: The journal entry.
@@ -168,7 +169,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
def delete_journal_entry(journal_entry: JournalEntry) -> Response:
"""Deletes a journal entry.
:param journal_entry: The journal entry.
@@ -186,31 +187,31 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
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)
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.
:param journal_entry_date: The date.
:param date: The date.
:return: The order of the journal entries in the date.
"""
journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == journal_entry_date) \
.order_by(JournalEntry.no).all()
journal_entries: list[JournalEntry] = db.session.scalars(
sa.select(JournalEntry).where(JournalEntry.date == date)
.order_by(JournalEntry.no)).all()
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)
def sort_journal_entries(journal_entry_date: date) -> redirect:
def sort_journal_entries(date: dt.date) -> Response:
"""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
reordering operation does not fail.
"""
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
form: JournalEntryReorderForm = JournalEntryReorderForm(date)
form.save_order()
if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success")
+3 -2
View File
@@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
from flask_babel_js import JAVASCRIPT, c2js
translation_dir: Path = Path(__file__).parent / "translations"
"""The directory of the translation files."""
domain: Domain = Domain(translation_directories=[translation_dir],
domain="accounting")
"""The message domain."""
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.
:return: None.
"""
bp.add_url_rule("/_jstrans.js", "babel_catalog",
__babel_js_catalog_view)
bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
app.jinja_env.globals["A_"] = domain.gettext
+178 -165
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,32 +21,31 @@ from __future__ import annotations
import datetime as dt
import re
import typing as t
from decimal import Decimal
from typing import Self
import sqlalchemy as sa
from babel import Locale
from flask_babel import get_locale, get_babel
from sqlalchemy import text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from accounting import db
from accounting.locale import gettext
from accounting.utils.user import user_cls, user_pk_column
from . import db
from .locale import gettext
from .utils.user import base_cls, user_cls, user_pk_column
class BaseAccount(db.Model):
class BaseAccount(base_cls):
"""A base account."""
__tablename__ = "accounting_base_accounts"
"""The table name."""
code: Mapped[str] = mapped_column(primary_key=True)
"""The code."""
"""The account code."""
title_l10n: Mapped[str] = mapped_column("title")
"""The title."""
l10n: Mapped[list[BaseAccountL10n]] \
= db.relationship(back_populates="account", lazy=False)
= relationship(back_populates="account", lazy=False)
"""The localized titles."""
accounts: Mapped[list[Account]] = db.relationship(back_populates="base")
accounts: Mapped[list[Account]] = relationship(back_populates="base")
"""The descendant accounts under the base account."""
def __str__(self) -> str:
@@ -54,7 +53,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account.
"""
return f"{self.code} {self.title.title()}"
return f"{self.code} {self.title}"
@property
def title(self) -> str:
@@ -79,16 +78,16 @@ class BaseAccount(db.Model):
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
class BaseAccountL10n(db.Model):
class BaseAccountL10n(base_cls):
"""A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n"
"""The table name."""
account_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"),
primary_key=True)
"""The code of the account."""
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
"""The account code."""
account: Mapped[BaseAccount] = relationship(back_populates="l10n")
"""The account."""
locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale."""
@@ -96,47 +95,47 @@ class BaseAccountL10n(db.Model):
"""The localized title."""
class Account(db.Model):
class Account(base_cls):
"""An account."""
__tablename__ = "accounting_accounts"
"""The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The account ID."""
base_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"))
"""The code of the base account."""
base: Mapped[BaseAccount] = db.relationship(back_populates="accounts")
base: Mapped[BaseAccount] = relationship(back_populates="accounts")
"""The base account."""
no: Mapped[int] = mapped_column(default=text("1"))
no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The account number under the base account."""
title_l10n: Mapped[str] = mapped_column("title")
"""The title."""
is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items of this account need offset."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of creation."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the creator."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of last update."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the updator."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
l10n: Mapped[list[AccountL10n]] \
= db.relationship(back_populates="account", lazy=False)
= relationship(back_populates="account", lazy=False)
"""The localized titles."""
line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="account")
= relationship(back_populates="account")
"""The journal entry line items."""
CASH_CODE: str = "1111-001"
@@ -151,7 +150,7 @@ class Account(db.Model):
: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
def code(self) -> str:
@@ -182,6 +181,8 @@ class Account(db.Model):
:param value: The new title.
:return: None.
"""
if self.title == value:
return
if self.title_l10n is None:
self.title_l10n = value
return
@@ -222,13 +223,13 @@ class Account(db.Model):
return getattr(self, "__count")
@count.setter
def count(self, count: int) -> None:
def count(self, value: int) -> None:
"""Sets the number of items in the account.
:param count: The number of items in the account.
:param value: The number of items in the account.
:return: None.
"""
setattr(self, "__count", count)
setattr(self, "__count", value)
@property
def query_values(self) -> list[str]:
@@ -266,31 +267,35 @@ class Account(db.Model):
:return: None.
"""
AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.id == self.id).delete()
db.session.execute(sa.delete(AccountL10n)
.where(AccountL10n.account == self))
cls: type[Self] = self.__class__
db.session.execute(sa.delete(cls).where(cls.id == self.id))
@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.
:param code: The code.
:return: The account, or None if this account does not exist.
"""
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
m: re.Match[str] | None = re.match(r"^([1-9]{4})-(\d{3})$", code)
if m is None:
return None
return cls.query.filter(cls.base_code == m.group(1),
cls.no == int(m.group(2))).first()
return db.session.scalar(
sa.select(cls).where(cls.base_code == m.group(1),
cls.no == int(m.group(2))))
@classmethod
def selectable_debit(cls) -> list[t.Self]:
def selectable_debit(cls) -> list[Self]:
"""Returns the selectable debit accounts.
Payable line items can not start from debit.
:return: The selectable debit accounts.
"""
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
return db.session.scalars(
sa.select(cls)
.where(sa.or_(cls.base_code.startswith("1"),
sa.and_(cls.base_code.startswith("2"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("3"),
@@ -302,18 +307,19 @@ class Account(db.Model):
cls.base_code.startswith("78"),
cls.base_code.startswith("8"),
cls.base_code.startswith("9")),
cls.base_code != "3351",
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()
cls.base_code != "3353")
.order_by(cls.base_code, cls.no)).unique().all()
@classmethod
def selectable_credit(cls) -> list[t.Self]:
def selectable_credit(cls) -> list[Self]:
"""Returns the selectable debit accounts.
Receivable line items can not start from credit.
:return: The selectable debit accounts.
"""
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
return db.session.scalars(
sa.select(cls)
.where(sa.or_(sa.and_(cls.base_code.startswith("1"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("2"),
cls.base_code.startswith("3"),
@@ -324,37 +330,40 @@ class Account(db.Model):
cls.base_code.startswith("74"),
cls.base_code.startswith("8"),
cls.base_code.startswith("9")),
cls.base_code != "3351",
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()
cls.base_code != "3353")
.order_by(cls.base_code, cls.no)).unique().all()
@classmethod
def cash(cls) -> t.Self:
def cash(cls) -> Self:
"""Returns the cash account.
:return: The cash account
"""
return cls.find_by_code(cls.CASH_CODE)
account: Self | None = cls.find_by_code(cls.CASH_CODE)
assert account is not None
return account
@classmethod
def accumulated_change(cls) -> t.Self:
def accumulated_change(cls) -> Self:
"""Returns the accumulated-change account.
:return: The accumulated-change account
"""
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
account: Self | None = cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
assert account is not None
return account
class AccountL10n(db.Model):
class AccountL10n(base_cls):
"""A localized account title."""
__tablename__ = "accounting_accounts_l10n"
"""The table name."""
account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"),
primary_key=True)
"""The account ID."""
account: Mapped[Account] = db.relationship(back_populates="l10n")
account: Mapped[Account] = relationship(back_populates="l10n")
"""The account."""
locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale."""
@@ -362,38 +371,38 @@ class AccountL10n(db.Model):
"""The localized title."""
class Currency(db.Model):
class Currency(base_cls):
"""A currency."""
__tablename__ = "accounting_currencies"
"""The table name."""
code: Mapped[str] = mapped_column(primary_key=True)
"""The code."""
"""The currency code."""
name_l10n: Mapped[str] = mapped_column("name")
"""The name."""
"""The currency name."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of creation."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the creator."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of last update."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the updator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] \
= db.relationship(foreign_keys=updated_by_id)
"""The updator."""
= relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
l10n: Mapped[list[CurrencyL10n]] \
= db.relationship(back_populates="currency", lazy=False)
= relationship(back_populates="currency", lazy=False)
"""The localized names."""
line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="currency")
= relationship(back_populates="currency")
"""The journal entry line items."""
def __str__(self) -> str:
@@ -424,6 +433,8 @@ class Currency(db.Model):
:param value: The new name.
:return: None.
"""
if self.name == value:
return
if self.name_l10n is None:
self.name_l10n = value
return
@@ -456,7 +467,7 @@ class Currency(db.Model):
:return: True if the currency can be deleted, or False otherwise.
"""
from accounting.template_globals import default_currency_code
from .template_globals import default_currency_code
if self.code == default_currency_code():
return False
return len(self.line_items) == 0
@@ -466,21 +477,22 @@ class Currency(db.Model):
:return: None.
"""
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.code == self.code).delete()
db.session.execute(
sa.delete(CurrencyL10n)
.where(CurrencyL10n.currency_code == self.code))
db.session.delete(self)
class CurrencyL10n(db.Model):
class CurrencyL10n(base_cls):
"""A localized currency name."""
__tablename__ = "accounting_currencies_l10n"
"""The table name."""
currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"),
primary_key=True)
"""The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="l10n")
currency: Mapped[Currency] = relationship(back_populates="l10n")
"""The currency."""
locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale."""
@@ -531,7 +543,7 @@ class JournalEntryCurrency:
return sum([x.amount for x in self.credit])
class JournalEntry(db.Model):
class JournalEntry(base_cls):
"""A journal entry."""
__tablename__ = "accounting_journal_entries"
"""The table name."""
@@ -539,30 +551,30 @@ class JournalEntry(db.Model):
"""The journal entry ID."""
date: Mapped[dt.date]
"""The date."""
no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the date."""
no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The journal entry number under the date."""
note: Mapped[str | None]
"""The note."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of creation."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the creator."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of last update."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the updator."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="journal_entry")
= relationship(back_populates="journal_entry")
"""The line items."""
def __str__(self) -> str:
@@ -643,48 +655,49 @@ class JournalEntry(db.Model):
:return: None.
"""
JournalEntryLineItem.query\
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
db.session.execute(
sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.journal_entry_id == self.id))
db.session.delete(self)
class JournalEntryLineItem(db.Model):
class JournalEntryLineItem(base_cls):
"""A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items"
"""The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The line item ID."""
journal_entry_id: Mapped[int] \
= mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(JournalEntry.id, onupdate="CASCADE",
ondelete="CASCADE"))
"""The journal entry ID."""
journal_entry: Mapped[JournalEntry] \
= db.relationship(back_populates="line_items")
= relationship(back_populates="line_items")
"""The journal entry."""
is_debit: Mapped[bool]
"""True for a debit line item, or False for a credit line item."""
no: Mapped[int]
"""The line item number under the journal entry and debit or credit."""
original_line_item_id: Mapped[int | None] \
= mapped_column(db.ForeignKey(id, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(id, onupdate="CASCADE"))
"""The ID of the original line item."""
original_line_item: Mapped[JournalEntryLineItem | None] \
= db.relationship(remote_side=id, passive_deletes=True)
= relationship(remote_side=id, passive_deletes=True)
"""The original line item."""
currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE"))
"""The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="line_items")
currency: Mapped[Currency] = relationship(back_populates="line_items")
"""The currency."""
account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE"))
"""The account ID."""
account: Mapped[Account] \
= db.relationship(back_populates="line_items", lazy=False)
= relationship(back_populates="line_items", lazy=False)
"""The account."""
description: Mapped[str | None]
"""The description."""
amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2))
amount: Mapped[Decimal] = mapped_column(sa.Numeric(14, 2))
"""The amount."""
def __str__(self) -> str:
@@ -693,7 +706,7 @@ class JournalEntryLineItem(db.Model):
:return: The string representation of the line item.
"""
if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
from .template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(description)s %(amount)s",
date=format_date(self.journal_entry.date),
@@ -735,13 +748,13 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__debit")
@debit.setter
def debit(self, debit: Decimal | None) -> None:
def debit(self, value: Decimal | None) -> None:
"""Sets the debit amount.
:param debit: The debit amount.
:param value: The debit amount.
:return: None.
"""
setattr(self, "__debit", debit)
setattr(self, "__debit", value)
@property
def credit(self) -> Decimal | None:
@@ -754,13 +767,13 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__credit")
@credit.setter
def credit(self, credit: Decimal | None) -> None:
def credit(self, value: Decimal | None) -> None:
"""Sets the credit amount.
:param credit: The credit amount.
:param value: The credit amount.
:return: None.
"""
setattr(self, "__credit", credit)
setattr(self, "__credit", value)
@property
def net_balance(self) -> Decimal:
@@ -775,45 +788,46 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__net_balance")
@net_balance.setter
def net_balance(self, net_balance: Decimal) -> None:
def net_balance(self, value: Decimal) -> None:
"""Sets the net balance.
:param net_balance: The net balance.
:param value: The net balance.
:return: None.
"""
setattr(self, "__net_balance", net_balance)
setattr(self, "__net_balance", value)
@property
def balance(self) -> Decimal:
"""Returns the net balance.
"""Returns the balance.
:return: The net balance.
:return: The balance.
"""
if not hasattr(self, "__balance"):
setattr(self, "__balance", Decimal("0"))
return getattr(self, "__balance")
@balance.setter
def balance(self, balance: Decimal) -> None:
"""Sets the net balance.
def balance(self, value: Decimal) -> None:
"""Sets the balance.
:param balance: The net balance.
:param value: The balance.
:return: None.
"""
setattr(self, "__balance", balance)
setattr(self, "__balance", value)
@property
def offsets(self) -> list[t.Self]:
def offsets(self) -> list[Self]:
"""Returns the offset items.
:return: The offset items.
"""
if not hasattr(self, "__offsets"):
cls: t.Type[t.Self] = self.__class__
offsets: list[t.Self] = cls.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
cls: type[Self] = self.__class__
offsets: list[Self] = db.session.scalars(
sa.select(cls).join(JournalEntry)
.where(cls.original_line_item_id == self.id)
.order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all()
cls.is_debit, cls.no)).unique().all()
setattr(self, "__offsets", offsets)
return getattr(self, "__offsets")
@@ -828,17 +842,16 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__is_offset")
@is_offset.setter
def is_offset(self, is_offset: bool) -> None:
def is_offset(self, value: bool) -> None:
"""Sets whether the line item is an offset.
:param is_offset: True if the line item is an offset, or False
otherwise.
:param value: True if the line item is an offset, or False otherwise.
:return: None.
"""
setattr(self, "__is_offset", is_offset)
setattr(self, "__is_offset", value)
@property
def match(self) -> t.Self | None:
def match(self) -> Self | None:
"""Returns the match of the line item.
:return: The match of the line item.
@@ -848,13 +861,13 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__match")
@match.setter
def match(self, match: t.Self) -> None:
def match(self, value: Self) -> None:
"""Sets the match of the line item.
:param match: The matcho of the line item.
:param value: The matcho of the line item.
:return: None.
"""
setattr(self, "__match", match)
setattr(self, "__match", value)
@property
def query_values(self) -> list[str]:
@@ -875,29 +888,29 @@ class JournalEntryLineItem(db.Model):
format_amount(self.amount)]
class Option(db.Model):
class Option(base_cls):
"""An option."""
__tablename__ = "accounting_options"
"""The table name."""
name: Mapped[str] = mapped_column(primary_key=True)
"""The name."""
value: Mapped[str] = mapped_column(db.Text)
value: Mapped[str] = mapped_column(sa.Text)
"""The option value."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of creation."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the creator."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The time of last update."""
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the updator."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator."""
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,13 +23,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, FieldList, FormField, IntegerField
from wtforms.validators import DataRequired, ValidationError
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
from ..forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
IsDebitAccount, IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import Options
from accounting.utils.strip_text import strip_text
from ..locale import lazy_gettext
from ..models import Account
from ..utils.current_account import CurrentAccount
from ..utils.options import Options
from ..utils.strip_text import strip_text
class CurrentAccountExists:
+9 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,16 +20,16 @@
from urllib.parse import parse_qsl, urlencode
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting.locale import lazy_gettext
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_admin
from .forms import OptionForm
from ..locale import lazy_gettext
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next
from ..utils.options import options
from ..utils.permission import has_permission, can_admin
bp: Blueprint = Blueprint("option", __name__)
"""The view blueprint for the currency management."""
@@ -64,7 +64,7 @@ def show_option_form() -> str:
@bp.post("update", endpoint="update")
@has_permission(can_admin)
def update_options() -> redirect:
def update_options() -> Response:
"""Updates the options.
:return: The redirection to the option form.
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ import re
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from .period import Period, get_period
from ..models import Account
from ..utils.current_account import CurrentAccount
class PeriodConverter(BaseConverter):
+16 -12
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,25 +20,29 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import typing as t
from datetime import date
import datetime as dt
from collections.abc import Callable
import sqlalchemy as sa
from accounting.models import JournalEntry
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
from ... import db
from ...models import JournalEntry
from ...utils.timezone import get_tz_today
class PeriodChooser:
"""The period chooser."""
def __init__(self, get_url: t.Callable[[Period], str]):
def __init__(self, get_url: Callable[[Period], str]):
"""Constructs a period chooser.
:param get_url: The callback to return the URL of the current report in
a period.
"""
self.__get_url: t.Callable[[Period], str] = get_url
self.__get_url: Callable[[Period], str] = get_url
"""The callback to return the URL of the current report in a period."""
# Shortcut periods
@@ -61,12 +65,12 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod())
"""The URL template."""
first: JournalEntry | None \
= JournalEntry.query.order_by(JournalEntry.date).first()
start: date | None = None if first is None else first.date
first: JournalEntry | None = db.session.scalar(
sa.select(JournalEntry).order_by(JournalEntry.date))
start: dt.date | None = None if first is None else first.date
# Attributes
self.data_start: date | None = start
self.data_start: dt.date | None = start
"""The start of the data."""
self.has_data: bool = start is not None
"""Whether there is any data."""
@@ -80,8 +84,8 @@ class PeriodChooser:
"""The available years."""
if self.has_data:
today: date = date.today()
self.has_last_month = start < date(today.year, today.month, 1)
today: dt.date = get_tz_today()
self.has_last_month = start < dt.date(today.year, today.month, 1)
self.has_last_year = start.year < today.year
self.has_yesterday = start < today
if start.year < today.year - 1:
+13 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,12 +17,12 @@
"""The period description composer.
"""
from datetime import date, timedelta
import datetime as dt
from accounting.locale import gettext
from ...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.
: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)
def __get_since_desc(start: date) -> str:
def __get_since_desc(start: dt.date) -> str:
"""Returns the description without the end day.
: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())
def __get_until_desc(end: date) -> str:
def __get_until_desc(end: dt.date) -> str:
"""Returns the description without the start day.
: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:
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_day(end)
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.
: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))
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.
: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.
: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
start_text: str = __format_month(start)
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))
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.
: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))
def __format_month(month: date) -> str:
def __format_month(month: dt.date) -> str:
"""Formats a month.
:param month: The month.
@@ -151,7 +151,7 @@ def __format_month(month: date) -> str:
return f"{month.year}/{month.month}"
def __format_day(day: date) -> str:
def __format_day(day: dt.date) -> str:
"""Formats a day.
:param day: The day.
+3 -3
View File
@@ -18,14 +18,14 @@
"""
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.
:param day: The date.
:return: The end day of the month of that day.
"""
last_day: int = calendar.monthrange(day.year, day.month)[1]
return date(day.year, day.month, last_day)
return dt.date(day.year, day.month, last_day)
+14 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,9 +18,9 @@
"""
import calendar
import datetime as dt
import re
import typing as t
from datetime import date
from collections.abc import Callable
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@@ -39,7 +39,7 @@ def get_period(spec: str | None = None) -> Period:
"""
if spec is None:
return ThisMonth()
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
named_periods: dict[str, type[Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(),
@@ -57,7 +57,7 @@ def get_period(spec: str | None = None) -> Period:
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.
:param text: The period specification.
@@ -67,6 +67,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
"""
if text == "-":
return None, None
m: re.Match[str] | None
m = re.match(f"^{DATE_SPEC_RE}$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
@@ -84,7 +85,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
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.
: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.
"""
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:
return date(int(year), int(month), 1)
return date(int(year), 1, 1)
return dt.date(int(year), int(month), 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.
: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.
"""
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:
year_n: int = int(year)
month_n: int = int(month)
day_n: int = calendar.monthrange(year_n, month_n)[1]
return date(year_n, month_n, day_n)
return date(int(year), 12, 31)
return dt.date(year_n, month_n, day_n)
return dt.date(int(year), 12, 31)
+10 -10
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).
"""
import typing as t
from datetime import date, timedelta
import datetime as dt
from typing import Self
from .description import get_desc
from .month_end import month_end
@@ -31,18 +31,18 @@ from .specification import get_spec
class 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.
:param start: The start date, or None from the very beginning.
:param end: The end date, or None till no end.
"""
self.start: date | None = start
self.start: dt.date | None = start
"""The start of the period."""
self.end: date | None = end
self.end: dt.date | None = end
"""The end of the period."""
self.is_default: bool = False
"""Whether the is the default period."""
"""Whether this is the default period."""
self.is_this_month: bool = False
"""Whether the period is this month."""
self.is_last_month: bool = False
@@ -95,8 +95,8 @@ class Period:
self.is_a_month = self.start.day == 1 \
and self.end == month_end(self.start)
self.is_type_month = self.is_a_month
self.is_a_year = self.start == date(self.start.year, 1, 1) \
and self.end == date(self.start.year, 12, 31)
self.is_a_year = self.start == dt.date(self.start.year, 1, 1) \
and self.end == dt.date(self.start.year, 12, 31)
self.is_a_day = self.start == self.end
def is_year(self, year: int) -> bool:
@@ -119,11 +119,11 @@ class Period:
and not self.is_a_day
@property
def before(self) -> t.Self | None:
def before(self) -> Self | None:
"""Returns the period before this period.
:return: The period before this period.
"""
if self.start is None:
return None
return Period(None, self.start - timedelta(days=1))
return Period(None, self.start - dt.timedelta(days=1))
+20 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,18 +17,19 @@
"""The named shortcut periods.
"""
from datetime import date, timedelta
import datetime as dt
from accounting.locale import gettext
from .month_end import month_end
from .period import Period
from ...locale import gettext
from ...utils.timezone import get_tz_today
class ThisMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
this_month_start: date = date(today.year, today.month, 1)
today: dt.date = get_tz_today()
this_month_start: dt.date = dt.date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today))
self.is_default = True
self.is_this_month = True
@@ -43,13 +44,13 @@ class ThisMonth(Period):
class LastMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
today: dt.date = get_tz_today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: date = date(year, month, 1)
start: dt.date = dt.date(year, month, 1)
super().__init__(start, month_end(start))
self.is_last_month = True
@@ -63,13 +64,13 @@ class LastMonth(Period):
class SinceLastMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
today: dt.date = get_tz_today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: date = date(year, month, 1)
start: dt.date = dt.date(year, month, 1)
super().__init__(start, None)
self.is_since_last_month = True
@@ -82,9 +83,9 @@ class SinceLastMonth(Period):
class ThisYear(Period):
"""The period of this year."""
def __init__(self):
year: int = date.today().year
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
year: int = get_tz_today().year
start: dt.date = dt.date(year, 1, 1)
end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end)
self.is_this_year = True
@@ -97,9 +98,9 @@ class ThisYear(Period):
class LastYear(Period):
"""The period of last year."""
def __init__(self):
year: int = date.today().year
start: date = date(year - 1, 1, 1)
end: date = date(year - 1, 12, 31)
year: int = get_tz_today().year
start: dt.date = dt.date(year - 1, 1, 1)
end: dt.date = dt.date(year - 1, 12, 31)
super().__init__(start, end)
self.is_last_year = True
@@ -112,7 +113,7 @@ class LastYear(Period):
class Today(Period):
"""The period of today."""
def __init__(self):
today: date = date.today()
today: dt.date = get_tz_today()
super().__init__(today, today)
self.is_today = True
@@ -125,7 +126,7 @@ class Today(Period):
class Yesterday(Period):
"""The period of yesterday."""
def __init__(self):
yesterday: date = date.today() - timedelta(days=1)
yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
super().__init__(yesterday, yesterday)
self.is_yesterday = True
@@ -163,6 +164,6 @@ class YearPeriod(Period):
:param year: The year.
"""
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
start: dt.date = dt.date(year, 1, 1)
end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end)
+20 -14
View File
@@ -17,22 +17,28 @@
"""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.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification.
"""
if start is None and end is None:
return "-"
if end is None:
return __get_since_spec(start)
if start is None:
return __get_until_spec(end)
return "-" if end is None else __get_until_spec(end)
return __get_since_spec(start) if end is None else __get_spec(start, end)
def __get_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification with both start and end.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification.
"""
try:
return __get_year_spec(start, end)
except ValueError:
@@ -44,7 +50,7 @@ def get_spec(start: date | None, end: date | None) -> str:
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.
:param start: The start of the period.
@@ -57,7 +63,7 @@ def __get_since_spec(start: date) -> str:
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.
:param end: The end of the period.
@@ -65,12 +71,12 @@ def __get_until_spec(end: date) -> str:
"""
if end.month == 12 and end.day == 31:
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-%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.
:param start: The start of the period.
@@ -88,7 +94,7 @@ def __get_year_spec(start: date, end: date) -> str:
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.
:param start: The start of the period.
@@ -96,7 +102,7 @@ def __get_month_spec(start: date, end: date) -> str:
:return: The period specification as a month range.
:raise ValueError: The period is not a month range.
"""
if start.day != 1 or (end + timedelta(days=1)).day != 1:
if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
raise ValueError
start_spec: str = start.strftime("%Y-%m")
if start.year == end.year and start.month == end.month:
@@ -105,7 +111,7 @@ def __get_month_spec(start: date, end: date) -> str:
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.
:param start: The start of the period.
+35 -32
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,20 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, balance_sheet_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
income_statement_url
class ReportAccount:
@@ -121,9 +119,9 @@ class AccountCollector:
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.end is not None:
@@ -135,16 +133,18 @@ class AccountCollector:
= sa.select(Account.id, Account.base_code, Account.no,
balance_func)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.where(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \
= db.session.execute(select_balance).all()
self.__all_accounts: list[Account] = Account.query\
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
self.__all_accounts: list[Account] = db.session.scalars(
sa.select(Account)
.where(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351",
Account.base_code == "3353")).all()
Account.base_code == "3353"))).unique().all()
"""The accounts."""
account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts}
self.accounts: list[ReportAccount] \
@@ -154,6 +154,7 @@ class AccountCollector:
account_by_id[x.id],
self.__period))
for x in account_balances]
"""The accounts on the balance sheet."""
self.__add_accumulated()
self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
@@ -178,7 +179,7 @@ class AccountCollector:
"""
if self.__period.start is None:
return None
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntry.date < self.__period.start]
return self.__query_balance(conditions)
@@ -197,7 +198,7 @@ class AccountCollector:
:return: The net income or loss for current period.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
@@ -206,7 +207,7 @@ class AccountCollector:
return self.__query_balance(conditions)
@staticmethod
def __query_balance(conditions: list[sa.BinaryExpression])\
def __query_balance(conditions: list[sa.ColumnElement[bool]])\
-> Decimal:
"""Queries the balance.
@@ -219,7 +220,7 @@ class AccountCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\
.join(JournalEntry).join(Account).filter(*conditions)
.join(JournalEntry).join(Account).where(*conditions)
return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
@@ -383,11 +384,13 @@ class BalanceSheet(BaseReport):
balances: list[ReportAccount] = AccountCollector(
self.__currency, self.__period).accounts
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
subtitles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
titles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_({"1", "2", "3"}))).unique().all()
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
subsections: dict[str, Subsection] = {x.code: Subsection(x)
@@ -452,11 +455,11 @@ class BalanceSheet(BaseReport):
:return: The CSV rows for the section.
"""
rows: list[CSVHalfRow] \
= [CSVHalfRow(section.title.title.title(), None)]
= [CSVHalfRow(section.title.title, None)]
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:
rows.append(CSVHalfRow(f" {str(account.account).title()}",
rows.append(CSVHalfRow(f" {str(account.account)}",
account.amount))
return rows
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,28 +17,26 @@
"""The income and expenses log.
"""
from datetime import date
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url
from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import income_expenses_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.current_account import CurrentAccount
from ...utils.pagination import Pagination
class ReportLineItem:
@@ -53,7 +51,7 @@ class ReportLineItem:
"""Whether this is the brought-forward line item."""
self.is_total: bool = False
"""Whether this is the total line item."""
self.date: date | None = None
self.date: dt.date | None = None
"""The date."""
self.account: Account | None = None
"""The account."""
@@ -119,12 +117,12 @@ class LineItemCollector:
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\
select: sa.Select[tuple[Decimal]] = sa.Select(balance_func)\
.join(JournalEntry).join(Account)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
.where(JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition,
JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select)
balance: Decimal | None = db.session.scalar(select)
if balance is None:
return None
line_item: ReportLineItem = ReportLineItem()
@@ -144,7 +142,7 @@ class LineItemCollector:
:return: The line items.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition]
if self.__period.start is not None:
@@ -152,12 +150,12 @@ class LineItemCollector:
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntryLineItem).join(Account).filter(*conditions)
join(JournalEntryLineItem).join(Account).where(*conditions)
return [ReportLineItem(x)
for x in JournalEntryLineItem.query
return [ReportLineItem(x) for x in db.session.scalars(
sa.select(JournalEntryLineItem)
.join(JournalEntry).join(Account)
.filter(JournalEntryLineItem.journal_entry_id
.where(JournalEntryLineItem.journal_entry_id
.in_(journal_entry_with_account),
JournalEntryLineItem.currency_code
== self.__currency.code,
@@ -167,10 +165,10 @@ class LineItemCollector:
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry))]
selectinload(JournalEntryLineItem.journal_entry)))]
@property
def __account_condition(self) -> sa.BinaryExpression:
def __account_condition(self) -> sa.ColumnElement[bool]:
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.sql_condition()
return Account.id == self.__account.id
@@ -213,7 +211,7 @@ class LineItemCollector:
class CSVRow(BaseCSVRow):
"""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,
description: str | None,
income: str | Decimal | None,
@@ -222,7 +220,7 @@ class CSVRow(BaseCSVRow):
note: str | None):
"""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 description: The description.
:param income: The income.
@@ -230,7 +228,7 @@ class CSVRow(BaseCSVRow):
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = journal_entry_date
self.date: dt.date | str | None = date
"""The date."""
self.account: str | None = account
"""The account."""
@@ -345,7 +343,7 @@ class PageParams(BasePageParams):
self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\
.filter(JournalEntryLineItem.currency_code == self.currency.code,
.where(JournalEntryLineItem.currency_code == self.currency.code,
CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x),
@@ -354,8 +352,10 @@ class PageParams(BasePageParams):
CurrentAccount(x),
self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()])
for x in db.session.scalars(
sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no))
.unique()])
return options
@@ -407,13 +407,13 @@ class IncomeExpenses(BaseReport):
gettext("Note"))]
if self.__brought_forward is not None:
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.income,
self.__brought_forward.expense,
self.__brought_forward.balance,
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)
for x in self.__line_items])
if self.__total is not None:
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,19 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, income_statement_url
class ReportAccount:
@@ -106,6 +105,7 @@ class Section:
"""The subsections in the section."""
self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title)
"""The accumulated total."""
@property
def total(self) -> Decimal:
@@ -218,19 +218,22 @@ class IncomeStatement(BaseReport):
"""
balances: list[ReportAccount] = self.__query_balances()
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
subtitles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
title_codes: set[str] = {"4", "5", "6", "7", "8", "9"}
titles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(title_codes))).unique().all()
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
total_titles: dict[str, str] \
= {"4": gettext("total operating revenue"),
"5": gettext("gross income"),
"6": gettext("operating income"),
"7": gettext("before tax income"),
"8": gettext("after tax income"),
"9": gettext("net income or loss for current period")}
= {"4": gettext("Total Operating Revenue"),
"5": gettext("Gross Income"),
"6": gettext("Operating Income"),
"7": gettext("Before Tax Income"),
"8": gettext("After Tax Income"),
"9": gettext("Net Income or Loss for Current Period")}
sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles}
@@ -253,9 +256,9 @@ class IncomeStatement(BaseReport):
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.start is not None:
@@ -267,14 +270,15 @@ class IncomeStatement(BaseReport):
else_=JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.where(*conditions)\
.group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.id for x in balances])).all()}
= {x.id: x for x in db.session.scalars(
sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
return [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=ledger_url(self.__currency,
@@ -300,14 +304,14 @@ class IncomeStatement(BaseReport):
total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections:
rows.append(CSVRow(str(section.title).title(), None))
rows.append(CSVRow(str(section.title), None))
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:
rows.append(CSVRow(f" {str(account.account).title()}",
rows.append(CSVRow(f" {str(account.account)}",
account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title.title(),
rows.append(CSVRow(section.accumulated.title,
section.accumulated.amount))
rows.append(CSVRow(None, None))
rows = rows[:-1]
+22 -22
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,25 +17,24 @@
"""The journal.
"""
from datetime import date
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import journal_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
class ReportLineItem:
@@ -67,7 +66,7 @@ class ReportLineItem:
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | date,
def __init__(self, journal_entry_date: str | dt.date,
currency: str,
account: str,
description: str | None,
@@ -84,7 +83,7 @@ class CSVRow(BaseCSVRow):
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | date = journal_entry_date
self.date: str | dt.date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
@@ -160,7 +159,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
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)
for x in line_items])
return rows
@@ -185,20 +184,21 @@ class Journal(BaseReport):
:return: The line items.
"""
conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = []
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(*conditions)\
return db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)\
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry))).all()
def csv(self) -> Response:
"""Returns the report as CSV for download.
+29 -30
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,27 +17,25 @@
"""The ledger.
"""
from datetime import date
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url
from accounting.utils.pagination import Pagination
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
class ReportLineItem:
@@ -52,7 +50,7 @@ class ReportLineItem:
"""Whether this is the brought-forward line item."""
self.is_total: bool = False
"""Whether this is the total line item."""
self.date: date | None = None
self.date: dt.date | None = None
"""The date."""
self.description: str | None = None
"""The description."""
@@ -117,7 +115,7 @@ class LineItemCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
.where(JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id,
JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select)
@@ -139,22 +137,22 @@ class LineItemCollector:
:return: The line items.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
return [ReportLineItem(x) for x in JournalEntryLineItem.query
.join(JournalEntry)
.filter(*conditions)
return [ReportLineItem(x) for x in db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry))
.all()]
.options(selectinload(JournalEntryLineItem.journal_entry)))
.unique()]
def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item.
@@ -196,7 +194,7 @@ class LineItemCollector:
class CSVRow(BaseCSVRow):
"""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,
debit: str | Decimal | None,
credit: str | Decimal | None,
@@ -204,14 +202,14 @@ class CSVRow(BaseCSVRow):
note: str | None):
"""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 debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = journal_entry_date
self.date: dt.date | str | None = date
"""The date."""
self.description: str | None = description
"""The description."""
@@ -310,12 +308,13 @@ class PageParams(BasePageParams):
:return: The account options.
"""
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(JournalEntryLineItem.currency_code == self.currency.code)\
.where(JournalEntryLineItem.currency_code == self.currency.code)\
.group_by(JournalEntryLineItem.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()]
for x in db.session.scalars(
sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no)).unique()]
class Ledger(BaseReport):
+43 -48
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,24 +17,25 @@
"""The search.
"""
from datetime import datetime
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from flask import Response, render_template, request
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
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 csv_download
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import csv_download
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ... import db
from ...locale import gettext
from ...models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
from ...utils.query import parse_query_keywords
class LineItemCollector:
@@ -53,9 +54,9 @@ class LineItemCollector:
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return []
conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
sub_conditions: list[sa.BinaryExpression] \
sub_conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.description.icontains(k),
JournalEntryLineItem.account_id.in_(
self.__get_account_condition(k)),
@@ -69,15 +70,16 @@ class LineItemCollector:
except ArithmeticError:
pass
conditions.append(sa.or_(*sub_conditions))
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(*conditions)\
return db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry))).all()
@staticmethod
def __get_account_condition(k: str) -> sa.Select:
@@ -86,20 +88,20 @@ class LineItemCollector:
:param k: The keyword.
:return: The condition to filter the account.
"""
code: sa.BinaryExpression = Account.base_code + "-" \
code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.icontains(k))
conditions: list[sa.BinaryExpression] \
.where(AccountL10n.title.icontains(k))
conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k),
Account.title_l10n.icontains(k),
code.contains(k),
Account.id.in_(select_l10n)]
if k in gettext("Needs Offset"):
conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions))
return sa.select(Account.id).where(sa.or_(*conditions))
@staticmethod
def __get_currency_condition(k: str) -> sa.Select:
@@ -109,9 +111,9 @@ class LineItemCollector:
:return: The condition to filter the currency.
"""
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code).filter(
sa.or_(Currency.code.icontains(k),
.where(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code)\
.where(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
Currency.code.in_(select_l10n)))
@@ -122,45 +124,38 @@ class LineItemCollector:
:param k: The keyword.
:return: The condition to filter the journal entry.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntry.note.icontains(k)]
journal_entry_date: datetime
date: dt.datetime
try:
journal_entry_date = datetime.strptime(k, "%Y")
conditions.append(sa.extract("year", JournalEntry.date)
== journal_entry_date.year)
date = dt.datetime.strptime(k, "%Y")
conditions.append(
sa.extract("year", JournalEntry.date) == date.year)
except ValueError:
pass
try:
journal_entry_date = datetime.strptime(k, "%Y/%m")
date = dt.datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_(
sa.extract("year", JournalEntry.date)
== journal_entry_date.year,
sa.extract("month", JournalEntry.date)
== journal_entry_date.month))
sa.extract("year", JournalEntry.date) == date.year,
sa.extract("month", JournalEntry.date) == date.month))
except ValueError:
pass
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_(
sa.extract("month", JournalEntry.date)
== journal_entry_date.month,
sa.extract("day", JournalEntry.date)
== journal_entry_date.day))
sa.extract("month", JournalEntry.date) == date.month,
sa.extract("day", JournalEntry.date) == date.day))
except ValueError:
pass
try:
journal_entry_date = datetime.strptime(k, "%Y/%m/%d")
date = dt.datetime.strptime(k, "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("year", JournalEntry.date)
== journal_entry_date.year,
sa.extract("month", JournalEntry.date)
== journal_entry_date.month,
sa.extract("day", JournalEntry.date)
== journal_entry_date.day))
sa.extract("year", JournalEntry.date) == date.year,
sa.extract("month", JournalEntry.date) == date.month,
sa.extract("day", JournalEntry.date) == date.day))
except ValueError:
pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
return sa.select(JournalEntry.id).where(sa.or_(*conditions))
class PageParams(BasePageParams):
+18 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,19 +22,17 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import Response, render_template
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, trial_balance_url
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, trial_balance_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
class ReportAccount:
@@ -178,7 +176,7 @@ class TrialBalance(BaseReport):
:return: None.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
@@ -189,14 +187,15 @@ class TrialBalance(BaseReport):
else_=-JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.where(*conditions)\
.group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.id for x in balances])).all()}
= {x.id: x for x in db.session.scalars(
sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
self.__accounts = [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=ledger_url(self.__currency,
@@ -224,7 +223,7 @@ class TrialBalance(BaseReport):
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
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])
rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit))
+24 -23
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,31 +17,31 @@
"""The unapplied original line items.
"""
from datetime import date
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, 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
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unapplied import get_accounts_with_unapplied, get_net_balances
from ..utils.urls import unapplied_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | date, currency: str,
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.
@@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
:param amount: The amount.
:param net_balance: The net balance.
"""
self.date: str | date = journal_entry_date
self.date: str | dt.date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
@@ -64,7 +64,7 @@ class CSVRow(BaseCSVRow):
"""The net balance."""
@property
def values(self) -> list[str | date | Decimal | None]:
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
@@ -178,13 +178,14 @@ class UnappliedOriginalLineItems(BaseReport):
"""
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)) \
line_items: list[JournalEntryLineItem] = db.session.scalars(
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.where(JournalEntryLineItem.id.in_(net_balances))
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,21 +17,21 @@
"""The accounts with unapplied original line items.
"""
from datetime import date
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
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unapplied import get_accounts_with_unapplied
from ..utils.urls import unapplied_url
from ...locale import gettext
from ...models import Currency, Account
class CSVRow(BaseCSVRow):
@@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | date | Decimal | None]:
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
@@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
rows.extend([CSVRow(str(x), x.count) for x in accounts])
return rows
@@ -143,7 +142,7 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport):
:return: The response of the report for download.
"""
filename: str = f"unapplied-accounts.csv"
filename: str = "unapplied-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
+17 -17
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,30 +17,30 @@
"""The unmatched offsets.
"""
from datetime import date
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
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.offset_matcher import OffsetMatcher, OffsetPair
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unmatched import get_accounts_with_unmatched
from ..utils.urls import unmatched_url
from ...locale import gettext
from ...models import Currency, Account, JournalEntryLineItem
from ...utils.pagination import Pagination
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | date, currency: str,
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.
@@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
:param credit: The credit amount.
:param balance: The balance.
"""
self.date: str | date = journal_entry_date
self.date: str | dt.date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
@@ -66,7 +66,7 @@ class CSVRow(BaseCSVRow):
"""The balance."""
@property
def values(self) -> list[str | date | Decimal | None]:
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,21 +17,21 @@
"""The accounts with unmatched offsets.
"""
from datetime import date
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
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unmatched import get_accounts_with_unmatched
from ..utils.urls import unmatched_url
from ...locale import gettext
from ...models import Currency, Account
class CSVRow(BaseCSVRow):
@@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | date | Decimal | None]:
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
@@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
rows.extend([CSVRow(str(x), x.count) for x in accounts])
return rows
@@ -144,7 +143,7 @@ class AccountsWithUnmatchedOffsets(BaseReport):
:return: The response of the report for download.
"""
filename: str = f"unapplied-accounts.csv"
filename: str = "unmatched-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
"""
from decimal import Decimal
from accounting.template_filters import format_amount as core_format_amount
from ..template_filters import format_amount as core_format_amount
def format_amount(value: Decimal | None) -> str | None:
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,19 +17,19 @@
"""The page parameters of a report.
"""
import typing as t
from abc import ABC, abstractmethod
from collections.abc import Callable
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Currency, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
from .option_link import OptionLink
from .report_chooser import ReportChooser
from ... import db
from ...models import Currency, JournalEntryLineItem
from ...utils.journal_entry_types import JournalEntryType
class BasePageParams(ABC):
@@ -52,7 +52,7 @@ class BasePageParams(ABC):
"""
@property
def journal_entry_types(self) -> t.Type[JournalEntryType]:
def journal_entry_types(self) -> type[JournalEntryType]:
"""Returns the journal entry types.
:return: The journal entry types.
@@ -72,7 +72,7 @@ class BasePageParams(ABC):
return urlunparse(parts)
@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]:
"""Returns the currency options.
@@ -84,5 +84,6 @@ class BasePageParams(ABC):
sa.select(JournalEntryLineItem.currency_code)
.group_by(JournalEntryLineItem.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
for x in db.session.scalars(
sa.select(Currency).where(Currency.code.in_(in_use))
.order_by(Currency.code)).unique()]
+19 -15
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,15 +18,15 @@
"""
import csv
import datetime as dt
from abc import ABC, abstractmethod
from datetime import timedelta, date
from decimal import Decimal
from io import StringIO
from urllib.parse import quote
from flask import Response
from accounting.report.period import Period
from ..period import Period
class BaseCSVRow(ABC):
@@ -66,18 +66,22 @@ def period_spec(period: Period) -> str:
"""
start: str | None = __get_start_str(period.start)
end: str | None = __get_end_str(period.end)
if period.start is None and period.end is None:
return "all-time"
if start == end:
return start
if period.start is None:
return f"until-{end}"
if period.end is None:
return f"since-{start}"
return f"{start}-{end}"
if start is None:
return "all-time" if end is None else f"until-{end}"
return f"since-{start}" if end is None else __get_spec(start, end)
def __get_start_str(start: date | None) -> str | None:
def __get_spec(start: str, end: str) -> str:
"""Constructs the period specification with both start and end
:param start: The start date.
:param end: The end date.
:return: The period specification.
"""
return start if start == end else f"{start}-{end}"
def __get_start_str(start: dt.date | None) -> str | None:
"""Returns the string representation of the start date.
:param start: The start date.
@@ -93,7 +97,7 @@ def __get_start_str(start: date | None) -> str | None:
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.
:param end: The end date.
@@ -104,6 +108,6 @@ def __get_end_str(end: date | None) -> str | None:
return None
if end.month == 12 and end.day == 31:
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%d")
+17 -18
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,10 +23,10 @@ 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
from ..utils.unapplied import get_net_balances
from ... import db
from ...locale import lazy_gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
class OffsetPair:
@@ -54,7 +54,7 @@ class OffsetMatcher:
:param currency: The currency.
:param account: The account.
"""
self.__currency: Account = currency
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
@@ -105,7 +105,7 @@ class OffsetMatcher:
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
unmatched_offset_condition: sa.BinaryExpression \
unmatched_offset_condition: sa.ColumnElement[bool] \
= sa.and_(Account.id == self.__account.id,
JournalEntryLineItem.currency_code
== self.__currency.code,
@@ -114,24 +114,23 @@ class OffsetMatcher:
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)) \
self.line_items = db.session.scalars(
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.where(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition))
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
for line_item in self.line_items:
line_item.is_offset = line_item.id in net_balances
self.unapplied = [x for x in self.line_items
if x.is_offset]
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 not x.is_offset]
self.unmatched = [x for x in self.line_items if x.is_offset]
self.__populate_accumulated_balances()
def __populate_accumulated_balances(self) -> None:
+10 -10
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,22 +21,22 @@ This file is largely taken from the NanoParma ERP project, first written in
"""
import re
import typing as t
from collections.abc import Iterator
from flask_babel import LazyString
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.permission import can_edit
from .option_link import OptionLink
from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url, \
unapplied_url, unmatched_url
from ..period import Period, get_period
from ... import db
from ...locale import gettext
from ...models import Currency, Account
from ...template_globals import default_currency_code
from ...utils.current_account import CurrentAccount
from ...utils.permission import can_edit
class ReportChooser:
@@ -190,7 +190,7 @@ class ReportChooser:
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question")
def __iter__(self) -> t.Iterator[OptionLink]:
def __iter__(self) -> Iterator[OptionLink]:
"""Returns the iteration of the reports.
:return: The iteration of the reports.
+9 -9
View File
@@ -22,21 +22,21 @@ from enum import Enum
class ReportType(Enum):
"""The report types."""
JOURNAL: str = "journal"
JOURNAL = "journal"
"""The journal."""
LEDGER: str = "ledger"
LEDGER = "ledger"
"""The ledger."""
INCOME_EXPENSES: str = "income-expenses"
INCOME_EXPENSES = "income-expenses"
"""The income and expenses log."""
TRIAL_BALANCE: str = "trial-balance"
TRIAL_BALANCE = "trial-balance"
"""The trial balance."""
INCOME_STATEMENT: str = "income-statement"
INCOME_STATEMENT = "income-statement"
"""The income statement."""
BALANCE_SHEET: str = "balance-sheet"
BALANCE_SHEET = "balance-sheet"
"""The balance sheet."""
UNAPPLIED: str = "unapplied"
UNAPPLIED = "unapplied"
"""The unapplied original line items."""
UNMATCHED: str = "unmatched"
UNMATCHED = "unmatched"
"""The unmatched offsets."""
SEARCH: str = "search"
SEARCH = "search"
"""The search."""
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,10 +21,9 @@ 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
from ... import db
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.offset_alias import offset_alias
def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
@@ -46,7 +45,7 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(Account.is_need_offset,
.where(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
@@ -59,13 +58,14 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
= 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))\
.where(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()
accounts: list[Account] = db.session.scalars(
sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts:
account.count = counts[account.id]
return accounts
@@ -92,7 +92,7 @@ def get_net_balances(currency: Currency, account: Account) \
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \
.filter(Account.id == account.id,
.where(Account.id == account.id,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
@@ -101,4 +101,4 @@ def get_net_balances(currency: Currency, account: Account) \
.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()}
for x in db.session.execute(select_net_balances)}
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,9 +19,8 @@
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from ... import db
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
@@ -36,7 +35,7 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset,
.where(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"),
@@ -47,8 +46,9 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
.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()
accounts: list[Account] = db.session.scalars(
sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts:
account.count = counts[account.id]
return accounts
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,11 +19,11 @@
"""
from flask import url_for
from accounting.models import Currency, Account
from accounting.report.period import Period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import options
from ...models import Currency, Account
from ...report.period import Period
from ...template_globals import default_currency_code
from ...utils.current_account import CurrentAccount
from ...utils.options import options
def journal_url(period: Period) \
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,15 +19,6 @@
"""
from flask import Blueprint, request, Response, redirect, flash
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
from accounting.template_globals import default_currency_code
from accounting.utils.cast import s
from accounting.utils.current_account import CurrentAccount
from accounting.utils.next_uri import or_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view, can_edit
from .period import Period, get_period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search
@@ -38,6 +29,15 @@ from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
from .template_filters import format_amount
from .utils.offset_matcher import OffsetMatcher
from .utils.urls import unmatched_url
from .. import db
from ..locale import lazy_gettext
from ..models import Currency, Account
from ..template_globals import default_currency_code
from ..utils.cast import s
from ..utils.current_account import CurrentAccount
from ..utils.next_uri import or_next
from ..utils.options import options
from ..utils.permission import has_permission, can_view, can_edit
bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports."""
@@ -391,7 +391,7 @@ def get_unmatched(currency: Currency, account: Account) -> str | Response:
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
endpoint="match-offsets")
@has_permission(can_edit)
def match_offsets(currency: Currency, account: Account) -> redirect:
def match_offsets(currency: Currency, account: Account) -> Response:
"""Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets.
+1
View File
@@ -339,6 +339,7 @@ class BaseAccountSelector {
/**
* A base account option.
*
* @private
*/
class BaseAccountOption {
@@ -344,6 +344,7 @@ class DescriptionEditor {
/**
* An account option in the description editor.
*
* @private
*/
class DescriptionEditorAccount extends JournalEntryAccount {
@@ -415,6 +416,7 @@ class DescriptionEditorAccount extends JournalEntryAccount {
/**
* A suggested account.
*
* @private
*/
class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
@@ -432,6 +434,7 @@ class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
/**
* The account option that is specified or confirmed by the user.
*
* @private
*/
class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
@@ -25,6 +25,7 @@
/**
* The account selector.
*
* @private
*/
class JournalEntryAccountSelector {
@@ -89,7 +90,7 @@ class JournalEntryAccountSelector {
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor
this.lineItemEditor = lineItemEditor;
this.#debitCredit = debitCredit;
const prefix = `accounting-account-selector-${debitCredit}`;
this.#query = document.getElementById(`${prefix}-query`);
@@ -187,6 +188,7 @@ class JournalEntryAccountSelector {
/**
* An account option
*
* @private
*/
class JournalEntryAccountOption {
@@ -276,7 +276,6 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = originalLineItem.date;
this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableDescriptionAccount(false);
if (this.description === null) {
if (originalLineItem.description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty");
@@ -291,7 +290,9 @@ class JournalEntryLineItemEditor {
this.account = originalLineItem.account.copy();
this.isAccountConfirmed = false;
this.#accountText.innerText = this.account.text;
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.min = "0";
this.#validate();
+5
View File
@@ -175,6 +175,7 @@ class OptionForm {
/**
* The recurring expenses or incomes sub-form.
*
* @private
*/
class RecurringExpenseIncomeSubForm {
@@ -350,6 +351,7 @@ class RecurringExpenseIncomeSubForm {
/**
* A recurring item sub-form.
*
* @private
*/
class RecurringItemSubForm {
@@ -551,6 +553,7 @@ class RecurringItemSubForm {
/**
* The recurring item editor.
*
* @private
*/
class RecurringItemEditor {
@@ -829,6 +832,7 @@ class RecurringItemEditor {
/**
* The account selector for the recurring item editor.
*
* @private
*/
class RecurringAccountSelector {
@@ -941,6 +945,7 @@ class RecurringAccountSelector {
/**
* An account in the account selector for the recurring item editor.
*
* @private
*/
class RecurringAccount {
@@ -25,6 +25,7 @@
/**
* The original line item selector.
*
* @private
*/
class OriginalLineItemSelector {
@@ -190,6 +191,7 @@ class OriginalLineItemSelector {
/**
* An original line item.
*
* @private
*/
class OriginalLineItem {
+5 -4
View File
@@ -2,7 +2,7 @@
* period-chooser.js: The JavaScript for the period chooser
*/
/* Copyright (c) 2023 imacat.
/* Copyright (c) 2023-2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@ document.addEventListener("DOMContentLoaded", () => {
/**
* The period chooser.
*
* @private
*/
class PeriodChooser {
@@ -320,7 +321,7 @@ class CustomTab extends TabPlane {
* The confirm button
* @type {HTMLButtonElement}
*/
#conform;
#confirm;
/**
* Constructs a tab plane.
@@ -333,7 +334,7 @@ class CustomTab extends TabPlane {
this.#startError = document.getElementById(`${this.prefix}-start-error`);
this.#end = document.getElementById(`${this.prefix}-end`);
this.#endError = document.getElementById(`${this.prefix}-end-error`);
this.#conform = document.getElementById(`${this.prefix}-confirm`);
this.#confirm = document.getElementById(`${this.prefix}-confirm`);
if (this.#start !== null) {
this.#start.onchange = () => {
if (this.#validateStart()) {
@@ -345,7 +346,7 @@ class CustomTab extends TabPlane {
this.#start.max = this.#end.value;
}
};
this.#conform.onclick = () => {
this.#confirm.onclick = () => {
let isValid = true;
isValid = this.#validateStart() && isValid;
isValid = this.#validateEnd() && isValid;
+37
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`;
}
+12 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,13 +17,14 @@
"""The template filters.
"""
import typing as t
from datetime import date, timedelta
import datetime as dt
from decimal import Decimal
from typing import Any
from flask_babel import get_locale
from accounting.locale import gettext
from .locale import gettext
from .utils.timezone import get_tz_today
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:]
def format_date(value: date) -> str:
def format_date(value: dt.date) -> str:
"""Formats a date to be human-friendly.
:param value: The date.
:return: The human-friendly date text.
"""
today: date = date.today()
today: dt.date = get_tz_today()
if value == today:
return gettext("Today")
if value == today - timedelta(days=1):
if value == today - dt.timedelta(days=1):
return gettext("Yesterday")
if value == today + timedelta(days=1):
if value == today + dt.timedelta(days=1):
return gettext("Tomorrow")
locale = str(get_locale())
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")
if value == today + timedelta(days=2):
if value == today + dt.timedelta(days=2):
return gettext("The day after tomorrow")
if locale == "zh" or locale.startswith("zh_"):
weekdays = ["", "", "", "", "", "", ""]
@@ -71,7 +72,7 @@ def format_date(value: date) -> str:
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.
:param value: The value.
+8 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,8 +17,11 @@
"""The template globals.
"""
from accounting.models import Currency
from accounting.utils.options import options
import sqlalchemy as sa
from . import db
from .models import Currency
from .utils.options import options
def currency_options() -> list[Currency]:
@@ -26,7 +29,8 @@ def currency_options() -> list[Currency]:
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
return db.session.scalars(
sa.select(Currency).order_by(Currency.code)).unique().all()
def default_currency_code() -> str:
@@ -23,6 +23,6 @@ First written: 2023/2/1
{% 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 %}
@@ -90,7 +90,7 @@ First written: 2023/1/31
{% endif %}
<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>
{% if obj.is_need_offset %}
<div>
@@ -32,7 +32,7 @@ First written: 2023/1/30
{{ A_("New") }}
</a>
{% 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">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
@@ -33,7 +33,7 @@ First written: 2023/2/1
</div>
<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>
{% if obj.accounts %}
<div>
@@ -26,7 +26,7 @@ First written: 2023/1/26
{% block content %}
<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">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
@@ -2,7 +2,7 @@
The Mia! Accounting Project
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");
you may not use this file except in compliance with the License.
@@ -27,5 +27,6 @@ First written: 2023/1/27
{% block scripts %}
<script src="{{ url_for("accounting.babel_catalog") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/timezone.js") }}"></script>
{% block accounting_scripts %}{% endblock %}
{% endblock %}
@@ -23,6 +23,6 @@ First written: 2023/2/6
{% 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 %}
@@ -32,7 +32,7 @@ First written: 2023/2/6
{{ A_("New") }}
</a>
{% 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">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
@@ -23,6 +23,6 @@ First written: 2023/2/25
{% 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 %}
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The cash disbursement journal entry detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -21,7 +21,7 @@ First written: 2023/2/26
#}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %}
{% block as_transfer %}
<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-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
@@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28
#}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<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 class="modal-dialog">
<div class="modal-content">
@@ -26,7 +26,7 @@ First written: 2023/3/14
<div>
<div class="small">
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }}
{{ line_item.account.title }}
</div>
{% if line_item.description is not none %}
<div>{{ line_item.description }}</div>
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The account detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -36,12 +36,12 @@ First written: 2023/2/26
{{ A_("Edit") }}
</a>
{% 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>
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
{% if accounting_can_edit() %}
{% block as_trasfer %}{% endblock %}
{% block as_transfer %}{% endblock %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
@@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<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 class="modal-dialog">
<div class="modal-content">
@@ -42,7 +42,7 @@ First written: 2023/2/25
<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|title }}
{{ line_item.account.title }}
</div>
{{ line_item.description|accounting_default }}
</div>
@@ -38,7 +38,7 @@ First written: 2023/2/26
</div>
{% 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() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
@@ -23,6 +23,6 @@ First written: 2023/2/25
{% 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 %}
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The cash receipt journal entry detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -21,9 +21,9 @@ First written: 2023/2/26
#}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %}
{% block as_transfer %}
<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>
</a>
{% endblock %}
@@ -41,7 +41,7 @@ First written: 2023/2/26
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
<div>{{ currency.credit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>
@@ -23,6 +23,6 @@ First written: 2023/2/25
{% 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 %}
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The transfer journal entry detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -53,7 +53,7 @@ First written: 2023/2/26
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
<div>{{ currency.credit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>
@@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<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 class="modal-dialog">
<div class="modal-content">

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