74 Commits

Author SHA1 Message Date
ee5b447c23 Renamed the "journal_entry_date" variable to "date" in the "__form" method of the JournalEntryData class in the lib module of the test site. 2023-04-26 13:42:47 +08:00
25bfcf4aa4 Fixed the documentation of the balance pseudo property of the JournalEntryLineItem data model. 2023-04-26 13:40:48 +08:00
5956d2cd4c Renamed the "match" parameter to "value" in the setter of the "match" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:48 +08:00
833285d924 Renamed the "is_offset" parameter to "value" in the setter of the "is_offset" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:43 +08:00
dee4f5e83f Renamed the "balance" parameter to "value" in the setter of the "balance" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:37 +08:00
f0d1cae32d Renamed the "net_balance" parameter to "value" in the setter of the "net_balance" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:25 +08:00
5dc71697b3 Renamed the "credit" parameter to "value" in the setter of the "credit" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:20 +08:00
1bb1e03c08 Renamed the "debit" parameter to "value" in the setter of the "debit" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:09 +08:00
914ff92e0f Renamed the "count" parameter to "value" in the setter of the "count" pseudo property of the Account data model, for consistency. 2023-04-26 13:39:56 +08:00
8a1cf463b1 Renamed the "journal_entry_date" parameter to "date" in the constructor of the CSVRow class in the "accounting.report.reports.ledger" module. 2023-04-26 13:34:14 +08:00
d4cf224d6b Renamed the "journal_entry_date" parameter to "date" in the constructor of the CSVRow class in the "accounting.report.reports.income_expenses" module. 2023-04-26 13:33:50 +08:00
8d412ec00a Renamed the "journal_entry_date" parameter to "date" in the show_journal_entry_order route. 2023-04-26 13:32:42 +08:00
2986c518ce Renamed the "journal_entry_date" parameter to "date" in the sort_journal_entries route. 2023-04-26 13:32:30 +08:00
f1351243a6 Renamed the "journal_entry_date" parameter to "date" in the constructor of the JournalEntryReorderForm form. 2023-04-26 13:29:55 +08:00
969e8c76a6 Renamed the "journal_entry_date" parameter to "date" in the "sort_journal_entries_in" function in the "accounting.journal_entry.forms.reorder" module. 2023-04-26 13:29:14 +08:00
10f5e75752 Renamed the "journal_entry_date" variable to "date" in the "test_reorder" test of the JournalEntryReorderTestCase test case. 2023-04-26 13:28:07 +08:00
169b3c292a Renamed the "journal_entry_date" variable to "date" in the "__get_journal_entry_condition" method of the LineItemCollector class in the "accounting.report.reports.search" module. 2023-04-26 13:26:38 +08:00
3eb3aef2f2 Renamed the "j_date" parameter to "date" in the "__next_j_no" method of the BaseTestData class in the lib module of the test site. 2023-04-26 13:24:19 +08:00
6c455a615c Renamed the "j_date" variable to "date" in the "_add_journal_entry" method of the BaseTestData class in the lib module of the test site. 2023-04-26 13:23:53 +08:00
4f3339bf68 Renamed the "j_date" variable to "date" in the "__add_usd_recurring" method of the SampleData class in the reset module of the test site. 2023-04-26 13:23:23 +08:00
b5aa7e923f Renamed the "j_date" variable to "date" in the "_init_data" method of the ReportTestData class in test_report.py. 2023-04-26 13:22:46 +08:00
359c335662 Revised the way to import from the datetime package, to avoid name conflict with the common "date" and "time" names. 2023-04-26 13:17:31 +08:00
c11ae23885 Removed an unused import from the "accounting.utils.cast" module. 2023-04-26 13:15:18 +08:00
e083b11394 Removed the random_pk type alias, because autoincrement=False does not seem to work with it. 2023-04-24 23:29:58 +08:00
167990fc4c Renamed the random_id type alias to random_pk. 2023-04-24 20:22:33 +08:00
d5c1be3d80 Rewrote the data model declaration of the test site with the mapped type hint and the mapped columns in SQLAlchemy 2.0. 2023-04-24 14:02:56 +08:00
f6567794e0 Added the documentation to the authentication blueprint of the test site. 2023-04-24 14:00:32 +08:00
ded85d88f7 Added the timestamp, user_pk, and random_id type aliases to simplify the column definition of the data models. 2023-04-24 03:37:33 +08:00
6d780e9296 Revised the title of the change log. 2023-04-23 22:19:01 +08:00
86637267d3 Advanced to version 1.5.0. 2023-04-23 20:13:34 +08:00
71e97721aa Removed the documentation link from the documentation in intro.rst. It does not make sense for a circular link to itself. 2023-04-23 20:13:33 +08:00
5815608288 Added the VERSION constant to the accounting module for the package version, and revised pyproject.toml and conf.py to read the version from it. 2023-04-23 20:13:10 +08:00
5f75d93c6a Simplified README.rst. 2023-04-23 18:42:54 +08:00
118c4b458e Added the change log. 2023-04-23 18:42:42 +08:00
3f7e4c0dda Rewrote the data model declaration with the mapped type hint and the mapped columns in SQLAlchemy 2.0. Added "SQLAlchemy >= 2" to the dependencies. 2023-04-23 13:21:54 +08:00
eed4c923f6 Removed the "be" cast function to cast data type for the binary expressions. It is to be replaced by the mapped type hints. 2023-04-23 13:21:48 +08:00
09dd5ae541 Revised the long line in the JournalEntryConverter converter. 2023-04-23 09:52:21 +08:00
172a12b134 Fixed the type hint of the "currency_options" function. 2023-04-23 09:44:25 +08:00
f3c558f48a Advanced to version 1.4.1. 2023-04-22 18:22:47 +08:00
988757d30e Revised the JavaScript journal entry line item editor to only override the description with the description of the original line item when there is no existing description. 2023-04-20 00:28:28 +08:00
50cea90d1b Revised the JavaScript journal entry line item editor to allow editing the description for offsets and partially-offset original items. 2023-04-20 00:26:58 +08:00
71dfb6f003 Advanced to version 1.4.0. 2023-04-18 09:33:35 +08:00
be628b4aa1 Updated the Sphinx documentation. 2023-04-18 09:33:00 +08:00
5d444adec4 Updated the translation. 2023-04-18 09:32:38 +08:00
014d67f7b8 Removed the period filter from the unapplied original line items and unmatched offsets. It does not make sense for these two reports. 2023-04-18 09:21:42 +08:00
26b4d4388f Revised the imports in the "accounting.report.reports.unmatched" module. 2023-04-18 09:10:25 +08:00
6e2e92d0fb Removed the redundant currency from the title of the reports when the currency is the default currency. 2023-04-18 08:46:23 +08:00
b1f87cb707 Updated the icon of the unmatched offsets. 2023-04-18 08:20:51 +08:00
928dea8312 Added the account information to the original line item selector of the journal entry form. 2023-04-18 08:15:33 +08:00
b8cec8a2af Revised the account options in the report toolbar to be scrollable. 2023-04-18 08:10:33 +08:00
b6ae946f32 Removed the account code from the journal entry form for mobile screens. 2023-04-18 08:10:23 +08:00
a9acc18a6f Removed the account code from the journal entry detail for mobile screens. 2023-04-18 07:13:10 +08:00
5468010c87 Removed the account code from the account list with unmatched offsets for mobile screens. 2023-04-18 07:10:26 +08:00
b505e380df Removed the account code from the account list with unapplied original line items for mobile screens. 2023-04-18 07:10:15 +08:00
412da170e1 Added the get_net_balances function to the "accounting.report.utils.unapplied" module to replace the __get_net_balances methods of the OffsetMatcher and UnappliedOriginalLineItems classes. 2023-04-18 07:04:00 +08:00
fa237795cf Simplified the query for the unapplied original line items, replacing the offset matcher with its own query. 2023-04-18 01:26:02 +08:00
e2f854b5cc Changed the unmatched offsets from a module to a report, and to show both the unapplied original line items and the unmatched offsets instead of only the unmatched offsets, and added the accumulated balance, in order for ease of use. Removed the match information from the unapplied original line item report. Added the currency and period filters to both the unapplied original line item report and unmatched offset reports. 2023-04-18 01:12:04 +08:00
f8895e3bff Revised the documentation of the "__get_unmatched_offsets" method of the OffsetMatcher class. 2023-04-16 22:52:14 +08:00
84ad065782 Merged the "accounting.utils.unapplied" module into the "accounting.utils.offset_matcher" module as the "__get_unapplied" method of the OffsetMatcher class. It is only used in the offset matcher. 2023-04-16 22:51:46 +08:00
260e3cbe82 Advanced to version 1.3.3. 2023-04-13 09:56:16 +08:00
cd039520b6 Added permission checks to the reset routes in the test site. 2023-04-13 09:54:20 +08:00
05e652aa62 Changed the "_journal_entries" and "_line_items" properties in the BaseTestData class from protected to private, renaming them to "__journal_entries" and "__line_items", respectively. There is no need to access it from the child classes anymore. 2023-04-13 09:28:53 +08:00
5c9bf0638c Removed the "csv_data" pseudo property from BaseTestData. 2023-04-13 09:25:50 +08:00
bbc78433fd Moved the sample data generation from the make-sample.py script to the test site. The sample data is generated at real time. This avoids the problem with pre-recorded sample data that the beginning of the months and weeks changes with the day resetting the sample data. 2023-04-13 09:23:57 +08:00
7bcc2b28b2 Moved the JournalEntryLineItemData, JournalEntryCurrencyData, JournalEntryData, and BaseTestData classes from testlib.py to the ".lib" module in the test site. 2023-04-13 08:30:07 +08:00
c1d9ca284c Changed the new_form and update_form methods of the JournalEntryData class in testlib.py to receive the next URI as the parameter instead of the constant, so that the JournalEntryData class can move to other places. 2023-04-13 08:23:52 +08:00
165e28441a Changed the sample data format from JSON to CSV for the test site live demonstration. 2023-04-12 21:33:34 +08:00
621020b0f0 Advanced to version 1.3.2. 2023-04-12 18:05:13 +08:00
6ad36cfaa3 Updated the translation of the test site. 2023-04-12 18:05:13 +08:00
20b0412091 Added the sample data generation and database reset on the test site for live demonstration. 2023-04-12 18:05:13 +08:00
3ca246d3e0 Revised the strings in babel-utils.py and babel-utils-test-site.py. 2023-04-12 15:04:32 +08:00
85d1b13ccd Added the "populate" method to the BaseTestData class, and changed it so that the tests need to call the "populate" method to populate the data, so that it may return the data with populating the database in the future. 2023-04-12 12:28:34 +08:00
3bada28b8f Revised the BaseTestData class in testlib.py to add journal entries directly to the database instead of through the API, in order to allow the data to be reused, and to speed up the test. 2023-04-12 12:12:11 +08:00
8f2cef8d81 Revised the imports in the accounting.journal_entry.converters module. 2023-04-12 00:41:29 +08:00
97 changed files with 3250 additions and 1716 deletions

View File

@ -44,126 +44,18 @@ You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_. `release page`_ on the `Git repository`_.
Prerequisites
=============
You need a running Flask application with database user login.
The primary key of the user data model must be integer. You also
need at least one user.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above
* `Decimal.js`_ 6.4.3 or above
* `Tempus-Dominus`_ 6.4.3 or above
Configuration
=============
You need to pass the Flask *app* and an implementation of
`UserUtilityInterface`_ to the `init_app`_ function.
``UserUtilityInterface`` contains everything *Mia! Accounting* needs.
The following is an example configuration for *Mia! Accounting*.
::
from flask import Response, redirect
from .auth import current_user()
from .modules import User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
import accounting
class UserUtils(accounting.UserUtilityInterface[User]):
def can_view(self) -> bool:
return True
def can_edit(self) -> bool:
return "editor" in current_user().roles
def can_admin(self) -> bool:
return current_user().is_admin
def unauthorized(self) -> Response:
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
return User
@property
def pk_column(self) -> Column:
return User.id
@property
def current_user(self) -> User | None:
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
def get_pk(self, user: User) -> int:
return user.id
accounting.init_app(app, UserUtils())
... (Any other configuration) ...
return app
Database Initialization
=======================
After the configuration, run the ``accounting-init-db`` console
command to initialize the accounting database. You need to specify
the username of a user as the data creator.
::
% flask --app myapp accounting-init-db -u username
Navigation Menu
===============
Include the navigation menu in the `Bootstrap navigation bar`_ in your
base template:
::
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<div class="container-fluid">
...
<div id="collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
...
{% include "accounting/include/nav.html" %}
...
</ul>
...
</div>
</div>
</nav>
Check your Flask application and see how it works.
Documentation Documentation
============= =============
Refer to the `documentation on Read the Docs`_. Refer to the `documentation on Read the Docs`_.
Change Log
==========
Refer to the `change log`_.
Copyright Copyright
========= =========
@ -198,12 +90,5 @@ Authors
.. _PyPI project page: https://pypi.org/project/mia-accounting .. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases .. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting .. _Git repository: https://github.com/imacat/mia-accounting
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _Tempus-Dominus: https://getdatepicker.com
.. _UserUtilityInterface: https://mia-accounting.readthedocs.io/en/latest/accounting.utils.html#accounting.utils.user.UserUtilityInterface
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io .. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
.. _change log: https://mia-accounting.readthedocs.io/en/latest/changelog.html

View File

@ -76,6 +76,22 @@ accounting.report.reports.unapplied\_accounts module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.report.reports.unmatched module
------------------------------------------
.. automodule:: accounting.report.reports.unmatched
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unmatched\_accounts module
----------------------------------------------------
.. automodule:: accounting.report.reports.unmatched_accounts
:members:
:undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -28,6 +28,14 @@ accounting.report.utils.csv\_export module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.report.utils.offset\_matcher module
----------------------------------------------
.. automodule:: accounting.report.utils.offset_matcher
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.option\_link module accounting.report.utils.option\_link module
------------------------------------------- -------------------------------------------
@ -60,6 +68,14 @@ accounting.report.utils.unapplied module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.report.utils.unmatched module
----------------------------------------
.. automodule:: accounting.report.utils.unmatched
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.urls module accounting.report.utils.urls module
----------------------------------- -----------------------------------

View File

@ -13,7 +13,6 @@ Subpackages
accounting.journal_entry accounting.journal_entry
accounting.option accounting.option
accounting.report accounting.report
accounting.unmatched_offset
accounting.utils accounting.utils
Submodules Submodules

View File

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

View File

@ -52,14 +52,6 @@ accounting.utils.offset\_alias module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.offset\_matcher module
---------------------------------------
.. automodule:: accounting.utils.offset_matcher
:members:
:undoc-members:
:show-inheritance:
accounting.utils.options module accounting.utils.options module
------------------------------- -------------------------------
@ -108,14 +100,6 @@ accounting.utils.strip\_text module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.unapplied module
---------------------------------
.. automodule:: accounting.utils.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module accounting.utils.user module
---------------------------- ----------------------------

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

@ -0,0 +1,310 @@
Change Log
==========
Version 1.5.0
-------------
Released 2023/4/23
* Updated to require ``SQLAlchemy >= 2``.
* Added the change log.
* Added the ``VERSION`` constant to the ``accounting`` module for
the package version, and revised ``pyproject.toml`` and ``conf.py``
to read the version from it.
Version 1.4.1
-------------
Released 2023/4/22
* Updated to allow editing the description of the journal entry line
item with offsets or are offsetting to original line items.
* Updated not to override the existing description of a journal entry
line item after choosing the original line item to offset to.
Version 1.4.0
-------------
Released 2023/4/18
* Rewrote the unapplied original line items and unmatched offsets.
* The unapplied original line items and unmatched offsets are both
in the report submodule. They can be filtered with currency and
period now.
* Show the unapplied original line items and unmatched offsets
together, and added the accumulated balance in the unmatched
offset list, for ease of reference.
* Removed the account code from the journal entry detail and journal
entry form for mobile devices.
* Made the account options in the reports to be scrollable.
Version 1.3.3
-------------
Released 2023/4/13
Changed the sample data generation in the test site live demonstration
from pre-recorded data to real-time generation, to avoid the problem
with the start of months and weeks changed with the date of the
import.
Version 1.3.2
-------------
Released 2023/4/12
Added the sample data generation and database reset on the test site
for live demonstration.
Version 1.3.1
-------------
Released 2023/4/11
* Fixed the permission of the navigation menu of the unmatched offsets.
* Revised the test site to be more accessible as the live demonstration.
Version 1.3.0
-------------
Released 2023/4/11
Added the ``accounting-init-db`` console command to replace all the
other console commands to initialize the accounting database. The
test site does not work with previous versions (<1.3.0).
Version 1.2.1
-------------
Released 2023/4/9
Fixed the search result to allow full ``year/month/day``
specification.
Version 1.2.0
-------------
Released 2023/4/9
* Simplified the URL of the default reports.
* Fixed the crash with malformed Chinese translation.
* Fixed the crash when downloading CSV data with non-US-ASCII
filenames.
Version 1.1.0
-------------
Released 2023/4/9
* Added the unapplied original line item list, to track unpaid
payables, unreceived receivables, assets, prepaids, refundable
deposits, etc.
* Added the offset matcher to match unapplied original line items
with unmatched offsets.
Version 1.0.1
-------------
Released 2023/4/6
Documentation fixes.
Version 1.0.0
-------------
Released 2023/4/6
The first formal release in Flask.
Added the documentation.
Version 0.11.1 (Pre-release)
----------------------------
Released 2023/4/5
Removed the zero balances from the trial balance, the income
statement, and the balance sheet.
Version 0.11.0 (Pre-release)
----------------------------
Released 2023/4/5
* Renamed the project from ``mia-accounting-flask`` to
``mia-accounting``.
* Updated the URL of the reports, as the default views of the
accounting application.
* Updated ``README``.
* Various fixes.
Version 0.10.0 (Pre-release)
----------------------------
Released 2023/4/3
* Added the unauthorized method to the ``UserUtilityInterface``
interface to allow fine control to how to handle the case when the
user has not logged in.
* Revised the JavaScript description editor to respect the account
that the user has confirmed or specifically selected.
* Various fixes.
Version 0.9.1 (Pre-release)
---------------------------
Released 2023/3/24
* A distinguishable look in the option detail than the option form.
* A better look in the new journal entry forms when there is no line
item yet.
* Fixed the search in the original entry selector in the journal
entry form to always do a partial match, to fix the problem that
there is no match when typing is not finished yet.
* Fixed the search in the original entry selector to search the net
balance correctly.
* Replaced the ``editor`` and ``editor2`` accounts with the ``admin``
and ``editor`` accounts.
* Various fixes.
Version 0.9.0 (Pre-release)
---------------------------
Released 2023/3/23
Moved the settings from the ``.env`` file to the option table in the
database that can be set and updated on the web interface. Added the
settings page to show and update the settings.
Version 0.8.0 (Pre-release)
---------------------------
Released 2023/3/22
* Added the recurring transactions to the description editor.
* Added prevention to delete database objects that are essential or
referenced by others with foreign keys.
* Various fixes on the visual layout.
Version 0.7.0 (Pre-release)
---------------------------
Released 2023/3/21
* Renamed "transaction" to "journal entry", and "journal entry" to
"journal entry line item".
* Renamed ``summary`` to ``description``.
* Updated tempus-dominus from version 6.2.10 to 6.4.3.
* Fixed titles and capitalization.
* Fixed to search case-insensitively.
* Added favicon to the test site.
* Fixed the navigation menu when there is no matching endpoint.
* Various fixes.
Version 0.6.0 (Pre-release)
---------------------------
Released 2023/3/18
* Added offset tracking to the journal entries in the payable and
receivable accounts.
* Renamed the ``is_offset_needed`` column to ``is_need_offset`` in
the ``Account`` data model.
Version 0.5.0 (Pre-release)
---------------------------
Released 2023/3/10
Added the accounting reports.
Version 0.4.0 (Pre-release)
---------------------------
Released 2023/3/1
Added the transaction summary helper.
Version 0.3.1 (Pre-release)
---------------------------
Released 2023/2/28
* Fixed the error that cannot select any account when adding new
transactions.
* Fixed the database error when adding new transactions.
* Added the button to convert a cash income or cash expense
transaction to a transfer transaction.
Version 0.3.0 (Pre-release)
---------------------------
Released 2023/2/27
Added the transaction management.
Version 0.2.0 (Pre-release)
---------------------------
Released 2023/2/7
* Added the currency management.
* Changed the ``can_edit`` permission to at least require the user to
log in first.
* Changed the type hint of the ``current_user`` pseudo property of
the ``AbstractUserUtils`` class to return ``None`` when the user
has not logged in.
Version 0.1.1 (Pre-release)
---------------------------
Released 2023/2/3
Finalized the account management, with tests and reordering.
Version 0.1.0 (Pre-release)
---------------------------
Released 2023/2/3
Added the account management, and updated the API to initialize the
accounting application.
Version 0.0.0 (Pre-release)
---------------------------
Released 2023/2/3
Initial release with main account list, localization, pagination,
query, permission, Sphinx documentation, and a test case based on a
test demonstration site.

View File

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

View File

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

View File

@ -103,12 +103,6 @@ base template:
Check your Flask application and see how it works. Check your Flask application and see how it works.
Documentation
-------------
Refer to the `documentation on Read the Docs`_.
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping .. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw .. _live demonstration: https://accounting.imacat.idv.tw
@ -123,4 +117,3 @@ Refer to the `documentation on Read the Docs`_.
.. _Decimal.js: https://mikemcl.github.io/decimal.js .. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _Tempus-Dominus: https://getdatepicker.com .. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/ .. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -17,7 +17,7 @@
[project] [project]
name = "mia-accounting" name = "mia-accounting"
version = "1.3.1" dynamic = ["version"]
description = "A Flask accounting module." description = "A Flask accounting module."
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.11" requires-python = ">=3.11"
@ -34,6 +34,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"flask", "flask",
"SQLAlchemy >= 2",
"Flask-SQLAlchemy", "Flask-SQLAlchemy",
"Flask-WTF", "Flask-WTF",
"Flask-Babel >= 3", "Flask-Babel >= 3",
@ -49,6 +50,7 @@ test = [
[project.urls] [project.urls]
"Documentation" = "https://mia-accounting.readthedocs.io" "Documentation" = "https://mia-accounting.readthedocs.io"
"Change Log" = "https://mia-accounting.readthedocs.io/en/latest/changelog.html"
"Repository" = "https://github.com/imacat/mia-accounting" "Repository" = "https://github.com/imacat/mia-accounting"
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues" "Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
"Demonstration" = "https://accounting.imacat.idv.tw" "Demonstration" = "https://accounting.imacat.idv.tw"
@ -57,6 +59,9 @@ test = [
requires = ["setuptools>=42"] requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
version = {attr = "accounting.VERSION"}
[tool.setuptools.exclude-package-data] [tool.setuptools.exclude-package-data]
"*" = [ "*" = [
"babel.cfg", "babel.cfg",

View File

@ -24,6 +24,8 @@ from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import UserUtilityInterface from accounting.utils.user import UserUtilityInterface
VERSION: str = "1.5.0"
"""The package version."""
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
data_dir: Path = Path(__file__).parent / "data" data_dir: Path = Path(__file__).parent / "data"
@ -91,7 +93,4 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import option from . import option
option.init_app(bp) option.init_app(bp)
from . import unmatched_offset
unmatched_offset.init_app(bp)
app.register_blueprint(bp, url_prefix=url_prefix) app.register_blueprint(bp, url_prefix=url_prefix)

View File

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

View File

@ -30,7 +30,6 @@ from accounting import db
from accounting.forms import CurrencyExists from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem from accounting.models import JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
@ -75,8 +74,8 @@ class KeepCurrencyWhenHavingOffset:
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\ original_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\ = JournalEntryLineItem.query\
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(JournalEntryLineItem.id .filter(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items .in_({x.id.data for x in form.line_items

View File

@ -17,7 +17,7 @@
"""The line item sub-forms for the journal entry management. """The line item sub-forms for the journal entry management.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -33,7 +33,6 @@ from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
@ -198,13 +197,13 @@ class NotExceedingOriginalLineItemNetBalance:
existing_line_item_id \ existing_line_item_id \
= {x.id for x in form.journal_entry_form.obj.line_items} = {x.id for x in form.journal_entry_form.obj.line_items}
offset_total_func: sa.Function = sa.func.sum(sa.case( offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntryLineItem.is_debit == is_debit), (JournalEntryLineItem.is_debit == is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar( offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func) sa.select(offset_total_func)
.filter(be(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id
== original_line_item.id), == original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id))) JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None: if offset_total_but_form is None:
offset_total_but_form = Decimal("0") offset_total_but_form = Decimal("0")
@ -232,8 +231,7 @@ class NotLessThanOffsetTotal:
(JournalEntryLineItem.is_debit != is_debit, (JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\ else_=-JournalEntryLineItem.amount)))\
.filter(be(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id == form.id.data)
== form.id.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total) offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total: if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@ -267,6 +265,19 @@ class LineItemForm(FlaskForm):
self.journal_entry_form: JournalEntryForm | None = None self.journal_entry_form: JournalEntryForm | None = None
"""The source journal entry form.""" """The source journal entry form."""
@property
def account_title(self) -> str:
"""Returns the title of the account.
:return: The title of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return account.title
@property @property
def account_text(self) -> str: def account_text(self) -> str:
"""Returns the text representation of the account. """Returns the text representation of the account.
@ -296,7 +307,7 @@ class LineItemForm(FlaskForm):
return getattr(self, "____original_line_item") return getattr(self, "____original_line_item")
@property @property
def original_line_item_date(self) -> date | None: def original_line_item_date(self) -> dt.date | None:
"""Returns the text representation of the original line item. """Returns the text representation of the original line item.
:return: The text representation of the original line item. :return: The text representation of the original line item.

View File

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

View File

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

View File

@ -54,6 +54,14 @@ class DescriptionAccount:
""" """
return str(self.__account) return str(self.__account)
@property
def title(self) -> str:
"""Returns the account title.
:return: The account title.
"""
return self.__account.title
def add_freq(self, freq: int) -> None: def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account. """Adds the frequency of an account.

View File

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

View File

@ -17,7 +17,7 @@
"""The views for the journal entry management. """The views for the journal entry management.
""" """
from datetime import date import datetime as dt
from urllib.parse import parse_qsl, urlencode from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa import sqlalchemy as sa
@ -30,9 +30,9 @@ from accounting.locale import lazy_gettext
from accounting.models import JournalEntry from accounting.models import JournalEntry
from accounting.utils.cast import s from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \ from .template_filters import with_type, to_transfer, format_amount_input, \
@ -67,7 +67,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
form.validate() form.validate()
else: else:
form = journal_entry_op.form() form = journal_entry_op.form()
form.date.data = date.today() form.date.data = dt.date.today()
return journal_entry_op.render_create_template(form) return journal_entry_op.render_create_template(form)
@ -186,31 +186,31 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
return redirect(or_next(__get_default_page_uri())) return redirect(or_next(__get_default_page_uri()))
@bp.get("dates/<date:journal_entry_date>", endpoint="order") @bp.get("dates/<date:date>", endpoint="order")
@has_permission(can_view) @has_permission(can_view)
def show_journal_entry_order(journal_entry_date: date) -> str: def show_journal_entry_order(date: dt.date) -> str:
"""Shows the order of the journal entries in a same date. """Shows the order of the journal entries in a same date.
:param journal_entry_date: The date. :param date: The date.
:return: The order of the journal entries in the date. :return: The order of the journal entries in the date.
""" """
journal_entries: list[JournalEntry] = JournalEntry.query \ journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == journal_entry_date) \ .filter(JournalEntry.date == date) \
.order_by(JournalEntry.no).all() .order_by(JournalEntry.no).all()
return render_template("accounting/journal-entry/order.html", return render_template("accounting/journal-entry/order.html",
date=journal_entry_date, list=journal_entries) date=date, list=journal_entries)
@bp.post("dates/<date:journal_entry_date>", endpoint="sort") @bp.post("dates/<date:date>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_journal_entries(journal_entry_date: date) -> redirect: def sort_journal_entries(date: dt.date) -> redirect:
"""Reorders the journal entries in a date. """Reorders the journal entries in a date.
:param journal_entry_date: The date. :param date: The date.
:return: The redirection to the incoming account or the account list. The :return: The redirection to the incoming account or the account list. The
reordering operation does not fail. reordering operation does not fail.
""" """
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date) form: JournalEntryReorderForm = JournalEntryReorderForm(date)
form.save_order() form.save_order()
if not form.is_modified: if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success") flash(s(lazy_gettext("The order was not modified.")), "success")

View File

@ -19,6 +19,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import datetime as dt
import re import re
import typing as t import typing as t
from decimal import Decimal from decimal import Decimal
@ -27,24 +28,37 @@ import sqlalchemy as sa
from babel import Locale from babel import Locale
from flask_babel import get_locale, get_babel from flask_babel import get_locale, get_babel
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Mapped, mapped_column
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.utils.user import user_cls, user_pk_column from accounting.utils.user import user_cls, user_pk_column
timestamp: t.Type[dt.datetime] \
= t.Annotated[dt.datetime, mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())]
"""The timestamp."""
user_pk: t.Type[int] \
= t.Annotated[int, mapped_column(db.ForeignKey(user_pk_column,
onupdate="CASCADE"))]
"""The user primary key."""
random_pk: t.Type[int] \
= t.Annotated[int, mapped_column(primary_key=True, autoincrement=False)]
"""The random primary key."""
class BaseAccount(db.Model): class BaseAccount(db.Model):
"""A base account.""" """A base account."""
__tablename__ = "accounting_base_accounts" __tablename__ = "accounting_base_accounts"
"""The table name.""" """The table name."""
code = db.Column(db.String, nullable=False, primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The code."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
l10n = db.relationship("BaseAccountL10n", back_populates="account", l10n: Mapped[list[BaseAccountL10n]] \
lazy=False) = db.relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
accounts = db.relationship("Account", back_populates="base") accounts: Mapped[list[Account]] = db.relationship(back_populates="base")
"""The descendant accounts under the base account.""" """The descendant accounts under the base account."""
def __str__(self) -> str: def __str__(self) -> str:
@ -81,17 +95,16 @@ class BaseAccountL10n(db.Model):
"""A localized base account title.""" """A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n" __tablename__ = "accounting_base_accounts_l10n"
"""The table name.""" """The table name."""
account_code = db.Column(db.String, account_code: Mapped[str] \
db.ForeignKey(BaseAccount.code, = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
onupdate="CASCADE", ondelete="CASCADE"),
ondelete="CASCADE"), primary_key=True)
nullable=False, primary_key=True)
"""The code of the account.""" """The code of the account."""
account = db.relationship(BaseAccount, back_populates="l10n") account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
"""The account.""" """The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
title = db.Column(db.String, nullable=False) title: Mapped[str]
"""The localized title.""" """The localized title."""
@ -99,47 +112,37 @@ class Account(db.Model):
"""An account.""" """An account."""
__tablename__ = "accounting_accounts" __tablename__ = "accounting_accounts"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The account ID.""" """The account ID."""
base_code = db.Column(db.String, base_code: Mapped[str] \
db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"))
nullable=False)
"""The code of the base account.""" """The code of the base account."""
base = db.relationship(BaseAccount, back_populates="accounts") base: Mapped[BaseAccount] = db.relationship(back_populates="accounts")
"""The base account.""" """The base account."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the base account.""" """The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False) is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items of this account need offset.""" """Whether the journal entry line items of this account need offset."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of creation.""" """The time of creation."""
created_by_id = db.Column(db.Integer, created_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator.""" """The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id) created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator.""" """The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, updated_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of last update.""" """The time of last update."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator.""" """The updator."""
l10n = db.relationship("AccountL10n", back_populates="account", l10n: Mapped[list[AccountL10n]] \
lazy=False) = db.relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
line_items = db.relationship("JournalEntryLineItem", line_items: Mapped[list[JournalEntryLineItem]] \
back_populates="account") = db.relationship(back_populates="account")
"""The journal entry line items.""" """The journal entry line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
@ -225,13 +228,13 @@ class Account(db.Model):
return getattr(self, "__count") return getattr(self, "__count")
@count.setter @count.setter
def count(self, count: int) -> None: def count(self, value: int) -> None:
"""Sets the number of items in the account. """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. :return: None.
""" """
setattr(self, "__count", count) setattr(self, "__count", value)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
@ -352,16 +355,16 @@ class AccountL10n(db.Model):
"""A localized account title.""" """A localized account title."""
__tablename__ = "accounting_accounts_l10n" __tablename__ = "accounting_accounts_l10n"
"""The table name.""" """The table name."""
account_id = db.Column(db.Integer, account_id: Mapped[int] \
db.ForeignKey(Account.id, onupdate="CASCADE", = mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) primary_key=True)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="l10n") account: Mapped[Account] = db.relationship(back_populates="l10n")
"""The account.""" """The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
title = db.Column(db.String, nullable=False) title: Mapped[str]
"""The localized title.""" """The localized title."""
@ -369,35 +372,28 @@ class Currency(db.Model):
"""A currency.""" """A currency."""
__tablename__ = "accounting_currencies" __tablename__ = "accounting_currencies"
"""The table name.""" """The table name."""
code = db.Column(db.String, nullable=False, primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The code."""
name_l10n = db.Column("name", db.String, nullable=False) name_l10n: Mapped[str] = mapped_column("name")
"""The name.""" """The name."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of creation.""" """The time of creation."""
created_by_id = db.Column(db.Integer, created_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator.""" """The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id) created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator.""" """The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, updated_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of last update.""" """The time of last update."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by: Mapped[user_cls] \
= db.relationship(foreign_keys=updated_by_id)
"""The updator.""" """The updator."""
l10n = db.relationship("CurrencyL10n", back_populates="currency", l10n: Mapped[list[CurrencyL10n]] \
lazy=False) = db.relationship(back_populates="currency", lazy=False)
"""The localized names.""" """The localized names."""
line_items = db.relationship("JournalEntryLineItem", line_items: Mapped[list[JournalEntryLineItem]] \
back_populates="currency") = db.relationship(back_populates="currency")
"""The journal entry line items.""" """The journal entry line items."""
def __str__(self) -> str: def __str__(self) -> str:
@ -479,16 +475,16 @@ class CurrencyL10n(db.Model):
"""A localized currency name.""" """A localized currency name."""
__tablename__ = "accounting_currencies_l10n" __tablename__ = "accounting_currencies_l10n"
"""The table name.""" """The table name."""
currency_code = db.Column(db.String, currency_code: Mapped[str] \
db.ForeignKey(Currency.code, onupdate="CASCADE", = mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) primary_key=True)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="l10n") currency: Mapped[Currency] = db.relationship(back_populates="l10n")
"""The currency.""" """The currency."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
name = db.Column(db.String, nullable=False) name: Mapped[str]
"""The localized name.""" """The localized name."""
@ -539,37 +535,28 @@ class JournalEntry(db.Model):
"""A journal entry.""" """A journal entry."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_journal_entries"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The journal entry ID.""" """The journal entry ID."""
date = db.Column(db.Date, nullable=False) date: Mapped[dt.date]
"""The date.""" """The date."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the date.""" """The account number under the date."""
note = db.Column(db.String) note: Mapped[str | None]
"""The note.""" """The note."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of creation.""" """The time of creation."""
created_by_id = db.Column(db.Integer, created_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator.""" """The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id) created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator.""" """The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, updated_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of last update.""" """The time of last update."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator.""" """The updator."""
line_items = db.relationship("JournalEntryLineItem", line_items: Mapped[list[JournalEntryLineItem]] \
back_populates="journal_entry") = db.relationship(back_populates="journal_entry")
"""The line items.""" """The line items."""
def __str__(self) -> str: def __str__(self) -> str:
@ -659,44 +646,39 @@ class JournalEntryLineItem(db.Model):
"""A line item in the journal entry.""" """A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items" __tablename__ = "accounting_journal_entry_line_items"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The line item ID.""" """The line item ID."""
journal_entry_id = db.Column(db.Integer, journal_entry_id: Mapped[int] \
db.ForeignKey(JournalEntry.id, = mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE",
onupdate="CASCADE", ondelete="CASCADE"))
ondelete="CASCADE"),
nullable=False)
"""The journal entry ID.""" """The journal entry ID."""
journal_entry = db.relationship(JournalEntry, back_populates="line_items") journal_entry: Mapped[JournalEntry] \
= db.relationship(back_populates="line_items")
"""The journal entry.""" """The journal entry."""
is_debit = db.Column(db.Boolean, nullable=False) is_debit: Mapped[bool]
"""True for a debit line item, or False for a credit line item.""" """True for a debit line item, or False for a credit line item."""
no = db.Column(db.Integer, nullable=False) no: Mapped[int]
"""The line item number under the journal entry and debit or credit.""" """The line item number under the journal entry and debit or credit."""
original_line_item_id = db.Column(db.Integer, original_line_item_id: Mapped[int | None] \
db.ForeignKey(id, onupdate="CASCADE"), = mapped_column(db.ForeignKey(id, onupdate="CASCADE"))
nullable=True)
"""The ID of the original line item.""" """The ID of the original line item."""
original_line_item = db.relationship("JournalEntryLineItem", original_line_item: Mapped[JournalEntryLineItem | None] \
remote_side=id, passive_deletes=True) = db.relationship(remote_side=id, passive_deletes=True)
"""The original line item.""" """The original line item."""
currency_code = db.Column(db.String, currency_code: Mapped[str] \
db.ForeignKey(Currency.code, onupdate="CASCADE"), = mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE"))
nullable=False)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="line_items") currency: Mapped[Currency] = db.relationship(back_populates="line_items")
"""The currency.""" """The currency."""
account_id = db.Column(db.Integer, account_id: Mapped[int] \
db.ForeignKey(Account.id, = mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE"))
onupdate="CASCADE"),
nullable=False)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="line_items", lazy=False) account: Mapped[Account] \
= db.relationship(back_populates="line_items", lazy=False)
"""The account.""" """The account."""
description = db.Column(db.String, nullable=True) description: Mapped[str | None]
"""The description.""" """The description."""
amount = db.Column(db.Numeric(14, 2), nullable=False) amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2))
"""The amount.""" """The amount."""
def __str__(self) -> str: def __str__(self) -> str:
@ -742,7 +724,18 @@ class JournalEntryLineItem(db.Model):
:return: The debit amount, or None if this is not a debit line item. :return: The debit amount, or None if this is not a debit line item.
""" """
return self.amount if self.is_debit else None if not hasattr(self, "__debit"):
setattr(self, "__debit", self.amount if self.is_debit else None)
return getattr(self, "__debit")
@debit.setter
def debit(self, value: Decimal | None) -> None:
"""Sets the debit amount.
:param value: The debit amount.
:return: None.
"""
setattr(self, "__debit", value)
@property @property
def credit(self) -> Decimal | None: def credit(self) -> Decimal | None:
@ -750,7 +743,18 @@ class JournalEntryLineItem(db.Model):
:return: The credit amount, or None if this is not a credit line item. :return: The credit amount, or None if this is not a credit line item.
""" """
return None if self.is_debit else self.amount if not hasattr(self, "__credit"):
setattr(self, "__credit", None if self.is_debit else self.amount)
return getattr(self, "__credit")
@credit.setter
def credit(self, value: Decimal | None) -> None:
"""Sets the credit amount.
:param value: The credit amount.
:return: None.
"""
setattr(self, "__credit", value)
@property @property
def net_balance(self) -> Decimal: def net_balance(self) -> Decimal:
@ -765,13 +769,32 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__net_balance") return getattr(self, "__net_balance")
@net_balance.setter @net_balance.setter
def net_balance(self, net_balance: Decimal) -> None: def net_balance(self, value: Decimal) -> None:
"""Sets the net balance. """Sets the net balance.
:param net_balance: The net balance. :param value: The net balance.
:return: None. :return: None.
""" """
setattr(self, "__net_balance", net_balance) setattr(self, "__net_balance", value)
@property
def balance(self) -> Decimal:
"""Returns the balance.
:return: The balance.
"""
if not hasattr(self, "__balance"):
setattr(self, "__balance", Decimal("0"))
return getattr(self, "__balance")
@balance.setter
def balance(self, value: Decimal) -> None:
"""Sets the balance.
:param value: The balance.
:return: None.
"""
setattr(self, "__balance", value)
@property @property
def offsets(self) -> list[t.Self]: def offsets(self) -> list[t.Self]:
@ -788,6 +811,25 @@ class JournalEntryLineItem(db.Model):
setattr(self, "__offsets", offsets) setattr(self, "__offsets", offsets)
return getattr(self, "__offsets") return getattr(self, "__offsets")
@property
def is_offset(self) -> bool:
"""Returns whether the line item is an offset.
:return: True if the line item is an offset, or False otherwise.
"""
if not hasattr(self, "__is_offset"):
setattr(self, "__is_offset", False)
return getattr(self, "__is_offset")
@is_offset.setter
def is_offset(self, value: bool) -> None:
"""Sets whether the line item is an offset.
:param value: True if the line item is an offset, or False otherwise.
:return: None.
"""
setattr(self, "__is_offset", value)
@property @property
def match(self) -> t.Self | None: def match(self) -> t.Self | None:
"""Returns the match of the line item. """Returns the match of the line item.
@ -799,13 +841,13 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__match") return getattr(self, "__match")
@match.setter @match.setter
def match(self, match: t.Self) -> None: def match(self, value: t.Self) -> None:
"""Sets the match of the line item. """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. :return: None.
""" """
setattr(self, "__match", match) setattr(self, "__match", value)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
@ -822,6 +864,7 @@ class JournalEntryLineItem(db.Model):
self.journal_entry.date.month, self.journal_entry.date.month,
self.journal_entry.date.day), self.journal_entry.date.day),
"" if self.description is None else self.description, "" if self.description is None else self.description,
str(self.account),
format_amount(self.amount)] format_amount(self.amount)]
@ -829,27 +872,19 @@ class Option(db.Model):
"""An option.""" """An option."""
__tablename__ = "accounting_options" __tablename__ = "accounting_options"
"""The table name.""" """The table name."""
name = db.Column(db.String, nullable=False, primary_key=True) name: Mapped[str] = mapped_column(primary_key=True)
"""The name.""" """The name."""
value = db.Column(db.Text, nullable=False) value: Mapped[str] = mapped_column(db.Text)
"""The option value.""" """The option value."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of creation.""" """The time of creation."""
created_by_id = db.Column(db.Integer, created_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator.""" """The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id) created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator.""" """The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, updated_at: Mapped[timestamp]
server_default=db.func.now())
"""The time of last update.""" """The time of last update."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[user_pk] = mapped_column()
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator.""" """The updator."""

View File

@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com). 2021/9/16 by imacat (imacat@nanoparma.com).
""" """
import datetime as dt
import typing as t import typing as t
from datetime import date
from accounting.models import JournalEntry from accounting.models import JournalEntry
from .period import Period from .period import Period
@ -63,10 +63,10 @@ class PeriodChooser:
first: JournalEntry | None \ first: JournalEntry | None \
= JournalEntry.query.order_by(JournalEntry.date).first() = JournalEntry.query.order_by(JournalEntry.date).first()
start: date | None = None if first is None else first.date start: dt.date | None = None if first is None else first.date
# Attributes # Attributes
self.data_start: date | None = start self.data_start: dt.date | None = start
"""The start of the data.""" """The start of the data."""
self.has_data: bool = start is not None self.has_data: bool = start is not None
"""Whether there is any data.""" """Whether there is any data."""
@ -80,8 +80,8 @@ class PeriodChooser:
"""The available years.""" """The available years."""
if self.has_data: if self.has_data:
today: date = date.today() today: dt.date = dt.date.today()
self.has_last_month = start < date(today.year, today.month, 1) self.has_last_month = start < dt.date(today.year, today.month, 1)
self.has_last_year = start.year < today.year self.has_last_year = start.year < today.year
self.has_yesterday = start < today self.has_yesterday = start < today
if start.year < today.year - 1: if start.year < today.year - 1:

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@
"""The named shortcut periods. """The named shortcut periods.
""" """
from datetime import date, timedelta import datetime as dt
from accounting.locale import gettext from accounting.locale import gettext
from .month_end import month_end from .month_end import month_end
@ -27,8 +27,8 @@ from .period import Period
class ThisMonth(Period): class ThisMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
this_month_start: date = date(today.year, today.month, 1) this_month_start: dt.date = dt.date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today)) super().__init__(this_month_start, month_end(today))
self.is_default = True self.is_default = True
self.is_this_month = True self.is_this_month = True
@ -43,13 +43,13 @@ class ThisMonth(Period):
class LastMonth(Period): class LastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
year = year - 1 year = year - 1
month = 12 month = 12
start: date = date(year, month, 1) start: dt.date = dt.date(year, month, 1)
super().__init__(start, month_end(start)) super().__init__(start, month_end(start))
self.is_last_month = True self.is_last_month = True
@ -63,13 +63,13 @@ class LastMonth(Period):
class SinceLastMonth(Period): class SinceLastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
year = year - 1 year = year - 1
month = 12 month = 12
start: date = date(year, month, 1) start: dt.date = dt.date(year, month, 1)
super().__init__(start, None) super().__init__(start, None)
self.is_since_last_month = True self.is_since_last_month = True
@ -82,9 +82,9 @@ class SinceLastMonth(Period):
class ThisYear(Period): class ThisYear(Period):
"""The period of this year.""" """The period of this year."""
def __init__(self): def __init__(self):
year: int = date.today().year year: int = dt.date.today().year
start: date = date(year, 1, 1) start: dt.date = dt.date(year, 1, 1)
end: date = date(year, 12, 31) end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end) super().__init__(start, end)
self.is_this_year = True self.is_this_year = True
@ -97,9 +97,9 @@ class ThisYear(Period):
class LastYear(Period): class LastYear(Period):
"""The period of last year.""" """The period of last year."""
def __init__(self): def __init__(self):
year: int = date.today().year year: int = dt.date.today().year
start: date = date(year - 1, 1, 1) start: dt.date = dt.date(year - 1, 1, 1)
end: date = date(year - 1, 12, 31) end: dt.date = dt.date(year - 1, 12, 31)
super().__init__(start, end) super().__init__(start, end)
self.is_last_year = True self.is_last_year = True
@ -112,7 +112,7 @@ class LastYear(Period):
class Today(Period): class Today(Period):
"""The period of today.""" """The period of today."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
super().__init__(today, today) super().__init__(today, today)
self.is_today = True self.is_today = True
@ -125,7 +125,7 @@ class Today(Period):
class Yesterday(Period): class Yesterday(Period):
"""The period of yesterday.""" """The period of yesterday."""
def __init__(self): def __init__(self):
yesterday: date = date.today() - timedelta(days=1) yesterday: dt.date = dt.date.today() - dt.timedelta(days=1)
super().__init__(yesterday, yesterday) super().__init__(yesterday, yesterday)
self.is_yesterday = True self.is_yesterday = True
@ -163,6 +163,6 @@ class YearPeriod(Period):
:param year: The year. :param year: The year.
""" """
start: date = date(year, 1, 1) start: dt.date = dt.date(year, 1, 1)
end: date = date(year, 12, 31) end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end) super().__init__(start, end)

View File

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

View File

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

View File

@ -17,7 +17,7 @@
"""The journal. """The journal.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -67,7 +67,7 @@ class ReportLineItem:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: str | date, def __init__(self, journal_entry_date: str | dt.date,
currency: str, currency: str,
account: str, account: str,
description: str | None, description: str | None,
@ -84,7 +84,7 @@ class CSVRow(BaseCSVRow):
:param credit: The credit amount. :param credit: The credit amount.
:param note: The note. :param note: The note.
""" """
self.date: str | date = journal_entry_date self.date: str | dt.date = journal_entry_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""

View File

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

View File

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

View File

@ -17,30 +17,31 @@
"""The unapplied original line items. """The unapplied original line items.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Account, JournalEntryLineItem from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied from accounting.report.utils.unapplied import get_accounts_with_unapplied, \
get_net_balances
from accounting.report.utils.urls import unapplied_url from accounting.report.utils.urls import unapplied_url
from accounting.utils.offset_matcher import OffsetMatcher
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_edit
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """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, description: str | None, amount: str | Decimal,
net_balance: str | Decimal): net_balance: str | Decimal):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
@ -51,7 +52,7 @@ class CSVRow(BaseCSVRow):
:param amount: The amount. :param amount: The amount.
:param net_balance: The net balance. :param net_balance: The net balance.
""" """
self.date: str | date = journal_entry_date self.date: str | dt.date = journal_entry_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""
@ -63,7 +64,7 @@ class CSVRow(BaseCSVRow):
"""The net balance.""" """The net balance."""
@property @property
def values(self) -> list[str | date | Decimal | None]: def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row. """Returns the values of the row.
:return: The values of the row. :return: The values of the row.
@ -75,25 +76,25 @@ class CSVRow(BaseCSVRow):
class PageParams(BasePageParams): class PageParams(BasePageParams):
"""The HTML page parameters.""" """The HTML page parameters."""
def __init__(self, account: Account, def __init__(self, currency: Currency,
is_mark_matches: bool, account: Account,
pagination: Pagination[JournalEntryLineItem], pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]): line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account. :param account: The account.
:param is_mark_matches: Whether to mark the matched offsets.
:param pagination: The pagination. :param pagination: The pagination.
:param line_items: The line items. :param line_items: The line items.
""" """
self.currency: Currency = currency
"""The currency."""
self.account: Account = account self.account: Account = account
"""The account.""" """The account."""
self.pagination: Pagination[JournalEntryLineItem] = pagination self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination.""" """The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items self.line_items: list[JournalEntryLineItem] = line_items
"""The line items.""" """The line items."""
self.is_mark_matches: bool = is_mark_matches
"""Whether to mark the matched offsets."""
@property @property
def has_data(self) -> bool: def has_data(self) -> bool:
@ -109,22 +110,32 @@ class PageParams(BasePageParams):
:return: The report chooser. :return: The report chooser.
""" """
return ReportChooser(ReportType.UNAPPLIED, return ReportChooser(ReportType.UNAPPLIED, currency=self.currency,
account=self.account) account=self.account)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: unapplied_url(x, self.account), self.currency)
@property @property
def account_options(self) -> list[OptionLink]: def account_options(self) -> list[OptionLink]:
"""Returns the account options. """Returns the account options.
:return: The account options. :return: The account options.
""" """
options: list[OptionLink] = [OptionLink(gettext("Accounts"), options: list[OptionLink] \
unapplied_url(None), = [OptionLink(gettext("Accounts"),
False)] unapplied_url(self.currency, None),
options.extend([OptionLink(str(x), False)]
unapplied_url(x), options.extend(
x.id == self.account.id) [OptionLink(str(x), unapplied_url(self.currency, x),
for x in get_accounts_with_unapplied()]) x.id == self.account.id)
for x in get_accounts_with_unapplied(self.currency)])
return options return options
@ -146,27 +157,47 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
class UnappliedOriginalLineItems(BaseReport): class UnappliedOriginalLineItems(BaseReport):
"""The unapplied original line items.""" """The unapplied original line items."""
def __init__(self, account: Account): def __init__(self, currency: Currency, account: Account):
"""Constructs the unapplied original line items. """Constructs the unapplied original line items.
:param currency: The currency.
:param account: The account. :param account: The account.
""" """
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account self.__account: Account = account
"""The account.""" """The account."""
offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
self.__line_items: list[JournalEntryLineItem] \ self.__line_items: list[JournalEntryLineItem] \
= offset_matcher.unapplied = self.__query_line_items()
"""The line items.""" """The line items."""
self.__is_mark_matches: bool \
= can_edit() and len(offset_matcher.unmatched_offsets) > 0 def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Whether to mark the matched offsets.""" """Queries and returns the line items.
:return: The line items.
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(JournalEntryLineItem.id.in_(net_balances)) \
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
return line_items
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = f"unapplied-{self.__account.code}.csv" filename: str = "unapplied-{currency}-{account}.csv"\
.format(currency=self.__currency.code, account=self.__account.code)
return csv_download(filename, get_csv_rows(self.__line_items)) return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str: def html(self) -> str:
@ -177,8 +208,8 @@ class UnappliedOriginalLineItems(BaseReport):
pagination: Pagination[JournalEntryLineItem] \ pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items, = Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True) is_reversed=True)
params: PageParams = PageParams(account=self.__account, params: PageParams = PageParams(currency=self.__currency,
is_mark_matches=self.__is_mark_matches, account=self.__account,
pagination=pagination, pagination=pagination,
line_items=pagination.list) line_items=pagination.list)
return render_template("accounting/report/unapplied.html", return render_template("accounting/report/unapplied.html",

View File

@ -17,13 +17,13 @@
"""The accounts with unapplied original line items. """The accounts with unapplied original line items.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Account from accounting.models import Currency, Account
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from accounting.report.utils.csv_export import BaseCSVRow, csv_download
@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
"""The number of unapplied original line items.""" """The number of unapplied original line items."""
@property @property
def values(self) -> list[str | date | Decimal | None]: def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row. """Returns the values of the row.
:return: The values of the row. :return: The values of the row.
@ -60,11 +60,14 @@ class CSVRow(BaseCSVRow):
class PageParams(BasePageParams): class PageParams(BasePageParams):
"""The HTML page parameters.""" """The HTML page parameters."""
def __init__(self, accounts: list[Account]): def __init__(self, currency: Currency, accounts: list[Account]):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param currency: The currency.
:param accounts: The accounts. :param accounts: The accounts.
""" """
self.currency: Currency = currency
"""The currency."""
self.accounts: list[Account] = accounts self.accounts: list[Account] = accounts
"""The accounts.""" """The accounts."""
@ -82,7 +85,17 @@ class PageParams(BasePageParams):
:return: The report chooser. :return: The report chooser.
""" """
return ReportChooser(ReportType.UNAPPLIED) return ReportChooser(ReportType.UNAPPLIED, currency=self.currency,
account=None)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(lambda x: unapplied_url(x, None),
self.currency)
@property @property
def account_options(self) -> list[OptionLink]: def account_options(self) -> list[OptionLink]:
@ -90,13 +103,13 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
options: list[OptionLink] = [OptionLink(gettext("Accounts"), options: list[OptionLink] \
unapplied_url(None), = [OptionLink(gettext("Accounts"),
True)] unapplied_url(self.currency, None),
options.extend([OptionLink(str(x), True)]
unapplied_url(x), options.extend(
False) [OptionLink(str(x), unapplied_url(self.currency, x), False)
for x in self.accounts]) for x in self.accounts])
return options return options
@ -115,9 +128,14 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
class AccountsWithUnappliedOriginalLineItems(BaseReport): class AccountsWithUnappliedOriginalLineItems(BaseReport):
"""The accounts with unapplied original line items.""" """The accounts with unapplied original line items."""
def __init__(self): def __init__(self, currency: Currency):
"""Constructs the outstanding balances.""" """Constructs the outstanding balances.
self.__accounts: list[Account] = get_accounts_with_unapplied()
:param currency: The currency.
"""
self.__currency: Currency = currency
"""The currency."""
self.__accounts: list[Account] = get_accounts_with_unapplied(currency)
"""The accounts.""" """The accounts."""
def csv(self) -> Response: def csv(self) -> Response:
@ -134,4 +152,5 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
return render_template("accounting/report/unapplied-accounts.html", return render_template("accounting/report/unapplied-accounts.html",
report=PageParams(accounts=self.__accounts)) report=PageParams(currency=self.__currency,
accounts=self.__accounts))

View File

@ -0,0 +1,214 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unmatched offsets.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from flask_babel import LazyString
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_url
from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | dt.date, currency: str,
description: str | None, debit: str | Decimal,
credit: str | Decimal, balance: str | Decimal):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param description: The description.
:param debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
"""
self.date: str | dt.date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.description: str | None = description
"""The description."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.balance: str | Decimal = balance
"""The balance."""
@property
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.description, self.debit,
self.credit, self.balance]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: Account,
match_status: str | LazyString,
matched_pairs: list[OffsetPair],
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param match_status: The match status message.
:param matched_pairs: A list of matched pairs.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.match_status: str | LazyString = match_status
"""The match status message."""
self.matched_pairs: list[OffsetPair] = matched_pairs
"""A list of matched pairs."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNMATCHED, currency=self.currency,
account=self.account)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: unmatched_url(x, self.account), self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unmatched_url(self.currency, None),
False)]
options.extend(
[OptionLink(str(x), unmatched_url(self.currency, x),
x.id == self.account.id)
for x in get_accounts_with_unmatched(self.currency)])
return options
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param line_items: The line items.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Description"), gettext("Debit"),
gettext("Credit"), gettext("Balance"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
x.description, x.debit, x.credit, x.balance)
for x in line_items])
return rows
class UnmatchedOffsets(BaseReport):
"""The unmatched offsets."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the unmatched offsets.
:param currency: The currency.
:param account: The account.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
offset_matcher: OffsetMatcher \
= OffsetMatcher(self.__currency, self.__account)
self.__line_items: list[JournalEntryLineItem] \
= offset_matcher.line_items
"""The line items."""
self.__match_status: str | LazyString = offset_matcher.status
"""The match status message."""
self.__matched_pairs: list[OffsetPair] = offset_matcher.matched_pairs
"""A list of matched pairs."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unmatched-{currency}-{account}.csv"\
.format(currency=self.__currency.code, account=self.__account.code)
return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
match_status=self.__match_status,
matched_pairs=self.__matched_pairs,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unmatched.html",
report=params)

View File

@ -0,0 +1,157 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The accounts with unmatched offsets.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_url
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, account: str, count: int | str):
"""Constructs a row in the CSV.
:param account: The account.
:param count: The number of unapplied original line items.
"""
self.account: str = account
"""The currency."""
self.count: int | str = count
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.account, self.count]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param accounts: The accounts.
"""
self.currency: Currency = currency
"""The currency."""
self.accounts: list[Account] = accounts
"""The accounts."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.accounts) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNMATCHED, currency=self.currency,
account=None)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(lambda x: unmatched_url(x, None),
self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unmatched_url(self.currency, None),
True)]
options.extend(
[OptionLink(str(x), unmatched_url(self.currency, x), False)
for x in self.accounts])
return options
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param accounts: The accounts.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
return rows
class AccountsWithUnmatchedOffsets(BaseReport):
"""The accounts with unmatched offsets."""
def __init__(self, currency: Currency):
"""Constructs the outstanding balances.
:param currency: The currency.
"""
self.__currency: Currency = currency
"""The currency."""
self.__accounts: list[Account] \
= get_accounts_with_unmatched(currency)
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"unapplied-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
return render_template("accounting/report/unmatched-accounts.html",
report=PageParams(currency=self.__currency,
accounts=self.__accounts))

View File

@ -18,8 +18,8 @@
""" """
import csv import csv
import datetime as dt
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import timedelta, date
from decimal import Decimal from decimal import Decimal
from io import StringIO from io import StringIO
from urllib.parse import quote from urllib.parse import quote
@ -77,7 +77,7 @@ def period_spec(period: Period) -> str:
return f"{start}-{end}" return f"{start}-{end}"
def __get_start_str(start: date | None) -> str | None: def __get_start_str(start: dt.date | None) -> str | None:
"""Returns the string representation of the start date. """Returns the string representation of the start date.
:param start: The start date. :param start: The start date.
@ -93,7 +93,7 @@ def __get_start_str(start: date | None) -> str | None:
return start.strftime("%Y%m%d") return start.strftime("%Y%m%d")
def __get_end_str(end: date | None) -> str | None: def __get_end_str(end: dt.date | None) -> str | None:
"""Returns the string representation of the end date. """Returns the string representation of the end date.
:param end: The end date. :param end: The end date.
@ -104,6 +104,6 @@ def __get_end_str(end: date | None) -> str | None:
return None return None
if end.month == 12 and end.day == 31: if end.month == 12 and end.day == 31:
return str(end.year) return str(end.year)
if (end + timedelta(days=1)).day == 1: if (end + dt.timedelta(days=1)).day == 1:
return end.strftime("%Y%m") return end.strftime("%Y%m")
return end.strftime("%Y%m%d") return end.strftime("%Y%m%d")

View File

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

View File

@ -31,10 +31,12 @@ from accounting.models import Currency, Account
from accounting.report.period import Period, get_period from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount from accounting.utils.current_account import CurrentAccount
from accounting.utils.permission import can_edit
from .option_link import OptionLink from .option_link import OptionLink
from .report_type import ReportType from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \ from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url trial_balance_url, income_statement_url, balance_sheet_url, \
unapplied_url, unmatched_url
class ReportChooser: class ReportChooser:
@ -75,6 +77,8 @@ class ReportChooser:
self.__reports.append(self.__income_statement) self.__reports.append(self.__income_statement)
self.__reports.append(self.__balance_sheet) self.__reports.append(self.__balance_sheet)
self.__reports.append(self.__unapplied) self.__reports.append(self.__unapplied)
if can_edit():
self.__reports.append(self.__unmatched)
for report in self.__reports: for report in self.__reports:
if report.is_active: if report.is_active:
self.current_report = report.title self.current_report = report.title
@ -160,15 +164,32 @@ class ReportChooser:
""" """
account: Account = self.__account account: Account = self.__account
if not account.is_need_offset: if not account.is_need_offset:
return OptionLink(gettext("Unapplied Original Line Items"), return OptionLink(gettext("Unapplied Items"),
unapplied_url(None), unapplied_url(self.__currency, None),
self.__active_report == ReportType.UNAPPLIED, self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash") fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unapplied Original Line Items"), return OptionLink(gettext("Unapplied Items"),
unapplied_url(account), unapplied_url(self.__currency, self.__account),
self.__active_report == ReportType.UNAPPLIED, self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash") fa_icon="fa-solid fa-link-slash")
@property
def __unmatched(self) -> OptionLink:
"""Returns the unmatched offsets.
:return: The unmatched offsets.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, None),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question")
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, self.__account),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question")
def __iter__(self) -> t.Iterator[OptionLink]: def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports. """Returns the iteration of the reports.

View File

@ -36,5 +36,7 @@ class ReportType(Enum):
"""The balance sheet.""" """The balance sheet."""
UNAPPLIED: str = "unapplied" UNAPPLIED: str = "unapplied"
"""The unapplied original line items.""" """The unapplied original line items."""
UNMATCHED: str = "unmatched"
"""The unmatched offsets."""
SEARCH: str = "search" SEARCH: str = "search"
"""The search.""" """The search."""

View File

@ -17,33 +17,37 @@
"""The unapplied original line item utilities. """The unapplied original line item utilities.
""" """
from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import Account, JournalEntryLineItem from accounting.models import Currency, Account, JournalEntry, \
from accounting.utils.cast import be JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied() -> list[Account]: def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
"""Returns the accounts with unapplied original line items. """Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items. :return: The accounts with unapplied original line items.
""" """
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
net_balance: sa.Label \ net_balance: sa.Label \
= (JournalEntryLineItem.amount = (JournalEntryLineItem.amount
+ sa.func.sum(sa.case( + sa.func.sum(sa.case(
(be(offset.c.is_debit == JournalEntryLineItem.is_debit), (offset.c.is_debit == JournalEntryLineItem.is_debit,
offset.c.amount), offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
select_unapplied: sa.Select \ select_unapplied: sa.Select \
= sa.select(JournalEntryLineItem.id)\ = sa.select(JournalEntryLineItem.id)\
.join(Account)\ .join(JournalEntry).join(Account)\
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(Account.is_need_offset, .filter(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)), sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"), sa.and_(Account.base_code.startswith("1"),
@ -65,3 +69,36 @@ def get_accounts_with_unapplied() -> list[Account]:
for account in accounts: for account in accounts:
account.count = counts[account.id] account.count = counts[account.id]
return accounts return accounts
def get_net_balances(currency: Currency, account: Account) \
-> dict[int, Decimal | None]:
"""Returns the net balances of the unapplied line items of the account.
:param currency: The currency.
:param account: The account.
:return: The net balances of the unapplied line items of the account.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(offset.c.is_debit == JournalEntryLineItem.is_debit,
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance) \
.join(JournalEntry).join(Account) \
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \
.filter(Account.id == account.id,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit))) \
.group_by(JournalEntryLineItem.id) \
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
return {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}

View File

@ -14,26 +14,30 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The queries for the unmatched offset management. """The unmatched offset utilities.
""" """
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import Account, JournalEntryLineItem from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
def get_accounts_with_unmatched_offsets() -> list[Account]: def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
"""Returns the accounts with unmatched offsets. """Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets, with the "count" property set :return: The accounts with unmatched offsets, with the "count" property set
to the number of unmatched offsets. to the number of unmatched offsets.
""" """
count_func: sa.Label \ count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count") = sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\ select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account).join(JournalEntryLineItem, isouter=True)\ .select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset, .filter(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
JournalEntryLineItem.original_line_item_id.is_(None), JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit), JournalEntryLineItem.is_debit),

View File

@ -113,13 +113,35 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
currency=currency, period=period) currency=currency, period=period)
def unapplied_url(account: Account | None) -> str: def unapplied_url(currency: Currency, account: Account | None) -> str:
"""Returns the URL of the unapplied original line items. """Returns the URL of the unapplied original line items.
:param currency: The currency.
:param account: The account, or None to list the accounts with unapplied :param account: The account, or None to list the accounts with unapplied
original line items. original line items.
:return: The URL of the unapplied original line items. :return: The URL of the unapplied original line items.
""" """
if account is None: if account is None:
return url_for("accounting-report.unapplied-default") if currency.code == default_currency_code():
return url_for("accounting-report.unapplied", account=account) return url_for("accounting-report.unapplied-accounts-default")
return url_for("accounting-report.unapplied-accounts",
currency=currency)
return url_for("accounting-report.unapplied",
currency=currency, account=account)
def unmatched_url(currency: Currency, account: Account | None) -> str:
"""Returns the URL of the unmatched offset line items.
:param currency: The currency.
:param account: The account, or None to list the accounts with unmatched
offset line items.
:return: The URL of the unmatched offset line items.
"""
if account is None:
if currency.code == default_currency_code():
return url_for("accounting-report.unmatched-accounts-default")
return url_for("accounting-report.unmatched-accounts",
currency=currency)
return url_for("accounting-report.unmatched",
currency=currency, account=account)

View File

@ -17,20 +17,27 @@
"""The views for the report management. """The views for the report management.
""" """
from flask import Blueprint, request, Response from flask import Blueprint, request, Response, redirect, flash
from accounting import db from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from accounting.utils.cast import s
from accounting.utils.current_account import CurrentAccount from accounting.utils.current_account import CurrentAccount
from accounting.utils.next_uri import or_next
from accounting.utils.options import options from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view, can_edit
from .period import Period, get_period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search IncomeStatement, BalanceSheet, Search
from .reports.unapplied import UnappliedOriginalLineItems from .reports.unapplied import UnappliedOriginalLineItems
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
from .reports.unmatched import UnmatchedOffsets
from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
from .template_filters import format_amount from .template_filters import format_amount
from .utils.offset_matcher import OffsetMatcher
from .utils.urls import unmatched_url
bp: Blueprint = Blueprint("accounting-report", __name__) bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""
@ -278,34 +285,130 @@ def __get_balance_sheet(currency: Currency, period: Period) \
return report.html() return report.html()
@bp.get("unapplied", endpoint="unapplied-default") @bp.get("unapplied", endpoint="unapplied-accounts-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_unapplied() -> str | Response: def get_default_unapplied_accounts() -> str | Response:
"""Returns the accounts with unapplied original line items. """Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
return __get_unapplied_accounts(
db.session.get(Currency, default_currency_code()))
@bp.get("unapplied/<currency:currency>", endpoint="unapplied-accounts")
@has_permission(can_view)
def get_unapplied_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items.
"""
return __get_unapplied_accounts(currency)
def __get_unapplied_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items. :return: The accounts with unapplied original line items.
""" """
report: AccountsWithUnappliedOriginalLineItems \ report: AccountsWithUnappliedOriginalLineItems \
= AccountsWithUnappliedOriginalLineItems() = AccountsWithUnappliedOriginalLineItems(currency)
if "as" in request.args and request.args["as"] == "csv": if "as" in request.args and request.args["as"] == "csv":
return report.csv() return report.csv()
return report.html() return report.html()
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied") @bp.get("unapplied/<currency:currency>/<needOffsetAccount:account>",
endpoint="unapplied")
@has_permission(can_view) @has_permission(can_view)
def get_unapplied(account: Account) -> str | Response: def get_unapplied(currency: Currency, account: Account) -> str | Response:
"""Returns the unapplied original line items. """Returns the unapplied original line items.
:param currency: The currency.
:param account: The Account. :param account: The Account.
:return: The unapplied original line items. :return: The unapplied original line items in the period.
""" """
report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account) report: UnappliedOriginalLineItems \
= UnappliedOriginalLineItems(currency, account)
if "as" in request.args and request.args["as"] == "csv": if "as" in request.args and request.args["as"] == "csv":
return report.csv() return report.csv()
return report.html() return report.html()
@bp.get("unmatched", endpoint="unmatched-accounts-default")
@has_permission(can_edit)
def get_default_unmatched_accounts() -> str | Response:
"""Returns the accounts with unmatched offsets.
:return: The accounts with unmatched offsets.
"""
return __get_unmatched_accounts(
db.session.get(Currency, default_currency_code()))
@bp.get("unmatched/<currency:currency>", endpoint="unmatched-accounts")
@has_permission(can_edit)
def get_unmatched_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets.
"""
return __get_unmatched_accounts(currency)
def __get_unmatched_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets.
"""
report: AccountsWithUnmatchedOffsets \
= AccountsWithUnmatchedOffsets(currency)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unmatched/<currency:currency>/<needOffsetAccount:account>",
endpoint="unmatched")
@has_permission(can_edit)
def get_unmatched(currency: Currency, account: Account) -> str | Response:
"""Returns the unmatched offsets.
:param currency: The currency.
:param account: The Account.
:return: The unmatched offsets in the period.
"""
report: UnmatchedOffsets = UnmatchedOffsets(currency, account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
endpoint="match-offsets")
@has_permission(can_edit)
def match_offsets(currency: Currency, account: Account) -> redirect:
"""Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets.
"""
matcher: OffsetMatcher = OffsetMatcher(currency, account)
if len(matcher.matched_pairs) == 0:
flash(s(lazy_gettext("No more offset to match automatically.")),
"success")
return redirect(or_next(
unmatched_url(currency, account)))
matcher.match()
db.session.commit()
flash(s(lazy_gettext("Matched %(matches)s offsets.",
matches=len(matcher.matched_pairs))), "success")
return redirect(or_next(unmatched_url(currency, account)))
@bp.get("search", endpoint="search") @bp.get("search", endpoint="search")
@has_permission(can_view) @has_permission(can_view)
def search() -> str | Response: def search() -> str | Response:

View File

@ -39,6 +39,10 @@
.accounting-toolbar { .accounting-toolbar {
display: flex; display: flex;
} }
.accounting-toolbar-accounts {
max-height: 20rem;
overflow-y: scroll;
}
.accounting-toolbar .input-group > .input-group-text { .accounting-toolbar .input-group > .input-group-text {
padding: 0; padding: 0;
background-color: transparent; background-color: transparent;
@ -322,7 +326,7 @@ a.accounting-report-table-row {
font-style: italic; font-style: italic;
} }
.accounting-unapplied-table .accounting-report-table-row { .accounting-unapplied-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 5fr 1fr 1fr; grid-template-columns: 1fr 5fr 1fr 1fr;
} }
.accounting-unapplied-account-table .accounting-report-table-row { .accounting-unapplied-account-table .accounting-report-table-row {
display: flex; display: flex;
@ -331,6 +335,16 @@ a.accounting-report-table-row {
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row { .accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
display: block; display: block;
} }
.accounting-unmatched-table .accounting-report-table-row {
grid-template-columns: 1fr 5fr 1fr 1fr 1fr;
}
.accounting-unmatched-account-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-unmatched-account-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
/* The accounting report */ /* The accounting report */
.accounting-mobile-journal-credit { .accounting-mobile-journal-credit {

View File

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

View File

@ -202,6 +202,12 @@ class JournalEntryAccountOption {
*/ */
code; code;
/**
* The account title
* @type {string}
*/
title;
/** /**
* The account text * The account text
* @type {string} * @type {string}
@ -235,6 +241,7 @@ class JournalEntryAccountOption {
constructor(selector, element) { constructor(selector, element) {
this.#element = element; this.#element = element;
this.code = element.dataset.code; this.code = element.dataset.code;
this.title = element.dataset.title;
this.text = element.dataset.text; this.text = element.dataset.text;
this.#isInUse = element.classList.contains("accounting-account-is-in-use"); this.#isInUse = element.classList.contains("accounting-account-is-in-use");
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset"); this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");

View File

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

View File

@ -277,13 +277,16 @@ class JournalEntryLineItemEditor {
this.originalLineItemText = originalLineItem.text; this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text; this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableDescriptionAccount(false); this.#setEnableDescriptionAccount(false);
if (originalLineItem.description === "") { if (this.description === null) {
this.#descriptionControl.classList.remove("accounting-not-empty"); if (originalLineItem.description === "") {
} else { this.#descriptionControl.classList.remove("accounting-not-empty");
this.#descriptionControl.classList.add("accounting-not-empty"); } else {
this.#descriptionControl.classList.add("accounting-not-empty");
}
this.description = originalLineItem.description === ""? null: originalLineItem.description;
this.#descriptionText.innerText = originalLineItem.description;
} }
this.description = originalLineItem.description === ""? null: originalLineItem.description; this.#setEnableAccount(false);
this.#descriptionText.innerText = originalLineItem.description;
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.account = originalLineItem.account.copy(); this.account = originalLineItem.account.copy();
this.isAccountConfirmed = false; this.isAccountConfirmed = false;
@ -305,7 +308,7 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = null; this.originalLineItemDate = null;
this.originalLineItemText = null; this.originalLineItemText = null;
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true); this.#setEnableAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.account = null; this.account = null;
this.isAccountConfirmed = false; this.isAccountConfirmed = false;
@ -356,7 +359,7 @@ class JournalEntryLineItemEditor {
*/ */
saveAccount(account) { saveAccount(account) {
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.account = new JournalEntryAccount(account.code, account.text, account.isNeedOffset); this.account = new JournalEntryAccount(account.code, account.title, account.text, account.isNeedOffset);
this.isAccountConfirmed = true; this.isAccountConfirmed = true;
this.#accountText.innerText = account.text; this.#accountText.innerText = account.text;
this.#validateAccount(); this.#validateAccount();
@ -472,12 +475,13 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = null; this.originalLineItemDate = null;
this.originalLineItemText = null; this.originalLineItemText = null;
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true); this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
this.#descriptionControl.classList.remove("is-invalid"); this.#descriptionControl.classList.remove("is-invalid");
this.description = null; this.description = null;
this.#descriptionText.innerText = "" this.#descriptionText.innerText = ""
this.#descriptionError.innerText = "" this.#descriptionError.innerText = ""
this.#setEnableAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid"); this.#accountControl.classList.remove("is-invalid");
this.account = null; this.account = null;
@ -511,7 +515,7 @@ class JournalEntryLineItemEditor {
this.#originalLineItemContainer.classList.remove("d-none"); this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty"); this.#originalLineItemControl.classList.add("accounting-not-empty");
} }
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null); this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.description = lineItem.description; this.description = lineItem.description;
if (this.description === null) { if (this.description === null) {
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
@ -519,6 +523,7 @@ class JournalEntryLineItemEditor {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
} }
this.#descriptionText.innerText = this.description === null? "": this.description; this.#descriptionText.innerText = this.description === null? "": this.description;
this.#setEnableAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.account = lineItem.account; this.account = lineItem.account;
this.isAccountConfirmed = true; this.isAccountConfirmed = true;
if (this.account === null) { if (this.account === null) {
@ -547,25 +552,17 @@ class JournalEntryLineItemEditor {
} }
/** /**
* Sets the enable status of the description and account. * Sets the enable status of the account.
* *
* @param isEnabled {boolean} true to enable, or false otherwise * @param isEnabled {boolean} true to enable, or false otherwise
*/ */
#setEnableDescriptionAccount(isEnabled) { #setEnableAccount(isEnabled) {
if (isEnabled) { if (isEnabled) {
this.#descriptionControl.dataset.bsToggle = "modal";
this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#descriptionControl.classList.remove("accounting-disabled");
this.#descriptionControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal"; this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`; this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#accountControl.classList.remove("accounting-disabled"); this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable"); this.#accountControl.classList.add("accounting-clickable");
} else { } else {
this.#descriptionControl.dataset.bsToggle = "";
this.#descriptionControl.dataset.bsTarget = "";
this.#descriptionControl.classList.add("accounting-disabled");
this.#descriptionControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = ""; this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = ""; this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled"); this.#accountControl.classList.add("accounting-disabled");

View File

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

View File

@ -17,8 +17,8 @@
"""The template filters. """The template filters.
""" """
import datetime as dt
import typing as t import typing as t
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from flask_babel import get_locale from flask_babel import get_locale
@ -41,24 +41,24 @@ def format_amount(value: Decimal | None) -> str | None:
return "{:,}".format(whole) + str(abs(frac))[1:] return "{:,}".format(whole) + str(abs(frac))[1:]
def format_date(value: date) -> str: def format_date(value: dt.date) -> str:
"""Formats a date to be human-friendly. """Formats a date to be human-friendly.
:param value: The date. :param value: The date.
:return: The human-friendly date text. :return: The human-friendly date text.
""" """
today: date = date.today() today: dt.date = dt.date.today()
if value == today: if value == today:
return gettext("Today") return gettext("Today")
if value == today - timedelta(days=1): if value == today - dt.timedelta(days=1):
return gettext("Yesterday") return gettext("Yesterday")
if value == today + timedelta(days=1): if value == today + dt.timedelta(days=1):
return gettext("Tomorrow") return gettext("Tomorrow")
locale = str(get_locale()) locale = str(get_locale())
if locale == "zh" or locale.startswith("zh_"): if locale == "zh" or locale.startswith("zh_"):
if value == today - timedelta(days=2): if value == today - dt.timedelta(days=2):
return gettext("The day before yesterday") return gettext("The day before yesterday")
if value == today + timedelta(days=2): if value == today + dt.timedelta(days=2):
return gettext("The day after tomorrow") return gettext("The day after tomorrow")
if locale == "zh" or locale.startswith("zh_"): if locale == "zh" or locale.startswith("zh_"):
weekdays = ["", "", "", "", "", "", ""] weekdays = ["", "", "", "", "", "", ""]

View File

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

View File

@ -51,14 +51,6 @@ First written: 2023/1/26
{{ A_("Currencies") }} {{ A_("Currencies") }}
</a> </a>
</li> </li>
{% if accounting_can_edit() %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.unmatched-offset.") %} active {% endif %}" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-link-slash"></i>
{{ A_("Unmatched Offsets") }}
</a>
</li>
{% endif %}
{% if accounting_can_admin() %} {% if accounting_can_admin() %}
<li> <li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">

View File

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

View File

@ -184,7 +184,7 @@ First written: 2023/2/28
<div class="mt-3 accounting-description-editor-buttons"> <div class="mt-3 accounting-description-editor-buttons">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-account-confirmed" class="btn btn-primary mb-1 d-none" type="button"></button> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-account-confirmed" class="btn btn-primary mb-1 d-none" type="button"></button>
{% for account in description_editor.accounts %} {% for account in description_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> <button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-title="{{ account.title }}" data-text="{{ account }}">
{{ account }} {{ account }}
</button> </button>
{% endfor %} {% endfor %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -89,7 +89,7 @@ First written: 2023/3/8
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
<span class="d-none d-md-inline">{{ A_("Account") }}</span> <span class="d-none d-md-inline">{{ A_("Account") }}</span>
</button> </button>
<ul class="dropdown-menu" aria-labelledby="accounting-choose-account"> <ul class="dropdown-menu accounting-toolbar-accounts" aria-labelledby="accounting-choose-account">
{% for account in report.account_options %} {% for account in report.account_options %}
<li> <li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}"> <a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}

View File

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

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}

View File

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

View File

@ -21,12 +21,18 @@ First written: 2023/4/8
#} #}
{% extends "accounting/base.html" %} {% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Accounts with Unapplied Original Line Items") }}{% endblock %}{% endblock %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unapplied Items") }}{% else %}{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
{% with use_account_chooser = true %} {% with use_currency_chooser = true,
use_account_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %} {% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %} {% endwith %}
</div> </div>
@ -38,7 +44,13 @@ First written: 2023/4/8
{% if report.has_data %} {% if report.has_data %}
<div class="accounting-sheet"> <div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3"> <div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ A_("Accounts with Unapplied Original Line Items") }}</h2> <h2 class="text-center">
{% if report.currency.code == accounting_default_currency_code() %}
{{ A_("Accounts with Unapplied Items") }}
{% else %}
{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
{% endif %}
</h2>
</div> </div>
<div class="accounting-report-table accounting-unapplied-account-table"> <div class="accounting-report-table accounting-unapplied-account-table">
@ -49,8 +61,11 @@ First written: 2023/4/8
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for account in report.accounts %} {% for account in report.accounts %}
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", account=account) }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", currency=report.currency, account=account, period=report.period) }}">
<div>{{ account }}</div> <div>
<span class="d-none d-md-inline">{{ account.code }}</span>
{{ account.title|title }}
</div>
<div class="accounting-amount">{{ account.count }}</div> <div class="accounting-amount">{{ account.count }}</div>
</a> </a>
{% endfor %} {% endfor %}

View File

@ -23,14 +23,16 @@ First written: 2023/4/7
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{{ A_("Unapplied Original Line Items of %(account)s", account=report.account.title|title) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
{% with use_account_chooser = true %} {% with use_currency_chooser = true,
use_account_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %} {% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %} {% endwith %}
</div> </div>
@ -48,7 +50,6 @@ First written: 2023/4/7
<div class="accounting-report-table-header"> <div class="accounting-report-table-header">
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div> <div>{{ A_("Date") }}</div>
<div>{{ A_("Currency") }}</div>
<div>{{ A_("Description") }}</div> <div>{{ A_("Description") }}</div>
<div class="accounting-amount">{{ A_("Amount") }}</div> <div class="accounting-amount">{{ A_("Amount") }}</div>
<div class="accounting-amount">{{ A_("Net Balance") }}</div> <div class="accounting-amount">{{ A_("Net Balance") }}</div>
@ -56,15 +57,9 @@ First written: 2023/4/7
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for line_item in report.line_items %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row {% if report.is_mark_matches and not line_item.match %} accounting-report-table-row-danger {% endif %}" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
<div>{{ line_item.journal_entry.date|accounting_format_date }}</div> <div>{{ line_item.journal_entry.date|accounting_format_date }}</div>
<div>{{ line_item.currency.name }}</div> <div>{{ line_item.description|accounting_default }}</div>
<div>
{{ line_item.description|accounting_default }}
{% if report.is_mark_matches and line_item.match %}
<div>{{ A_("Can match %(offset)s", offset=line_item.match) }}</div>
{% endif %}
</div>
<div class="accounting-amount">{{ line_item.amount|accounting_format_amount }}</div> <div class="accounting-amount">{{ line_item.amount|accounting_format_amount }}</div>
<div class="accounting-amount">{{ line_item.net_balance|accounting_format_amount }}</div> <div class="accounting-amount">{{ line_item.net_balance|accounting_format_amount }}</div>
</a> </a>
@ -78,9 +73,6 @@ First written: 2023/4/7
<div> <div>
<div class="text-muted small"> <div class="text-muted small">
{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.journal_entry.date|accounting_format_date }}
{% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %}
</div> </div>
{% if line_item.description is not none %} {% if line_item.description is not none %}
<div>{{ line_item.description }}</div> <div>{{ line_item.description }}</div>

View File

@ -0,0 +1,79 @@
{#
The Mia! Accounting Project
unmatched-accounts.html: The account list with unmatched offsets
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/17
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unmatched Offsets") }}{% else %}{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_account_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">
{% if report.currency.code == accounting_default_currency_code() %}
{{ A_("Accounts with Unmatched Offsets") }}
{% else %}
{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
{% endif %}
</h2>
</div>
<div class="accounting-report-table accounting-unapplied-account-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div class="accounting-amount">{{ A_("Count") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for account in report.accounts %}
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unmatched", currency=report.currency, account=account, period=report.period) }}">
<div>
<span class="d-none d-md-inline">{{ account.code }}</span>
{{ account.title|title }}
</div>
<div class="accounting-amount">{{ account.count }}</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,150 @@
{#
The Mia! Accounting Project
unmatched.html: The unmatched offsets
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/17
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_account_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.matched_pairs %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-match-modal">
<i class="fa-solid fa-link"></i>
{{ A_("Match") }}
</button>
<form action="{{ url_for("accounting-report.match-offsets", currency=report.currency, account=report.account) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-match-modal-label">{{ A_("Confirm Match Offsets") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<p>{{ A_("Do you really want to match the following original line items with their offsets? This cannot be undone. Please backup your database first, and review before you confirm.") }}</p>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover accounting-unmatched-offset-pair-list">
{% for pair in report.matched_pairs %}
<li class="list-group-item">
{{ pair.offset.description|accounting_default }}
<span class="badge bg-info">{{ pair.offset.amount|accounting_format_amount }}</span>
{{ pair.original_line_item.journal_entry.date|accounting_format_date }} &rarr; {{ pair.offset.journal_entry.date|accounting_format_date }}
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<p>{{ report.match_status }}</p>
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}
{% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-unmatched-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Description") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
<div class="accounting-amount">{{ A_("Balance") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for line_item in report.line_items %}
<a class="accounting-report-table-row {% if not line_item.match %} accounting-report-table-row-danger {% endif %}" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
<div>{{ line_item.journal_entry.date|accounting_format_date }}</div>
<div>
{{ line_item.description|accounting_default }}
{% if line_item.match %}
<div class="small">{{ A_("Can match %(item)s", item=line_item.match) }}</div>
{% endif %}
</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
</a>
{% endfor %}
</div>
</div>
<div class="list-group d-md-none">
{% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
<div>
<div class="text-muted small">
{{ line_item.journal_entry.date|accounting_format_date }}
</div>
{% if line_item.description is not none %}
<div>{{ line_item.description }}</div>
{% endif %}
</div>
<div>
{% if line_item.debit %}
<span class="badge rounded-pill bg-success">+{{ line_item.debit|accounting_format_amount }}</span>
{% endif %}
{% if line_item.credit %}
<span class="badge rounded-pill bg-warning">-{{ line_item.credit|accounting_format_amount }}</span>
{% endif %}
{% if line_item.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
{% else %}
<span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -1,40 +0,0 @@
{#
The Mia! Accounting Project
dashboard.html: The account list with unmatched offsets
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/8
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Unmatched Offsets") }}{% endblock %}{% endblock %}
{% block content %}
{% if list %}
<div>
{% for account in list %}
<a class="btn btn-primary mb-1" role="button" href="{{ url_for("accounting.unmatched-offset.list", account=account) }}">
{{ account }} ({{ account.count }})
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -1,107 +0,0 @@
{#
The Mia! Accounting Project
list.html: The unmatched offset list
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/8
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Unmatched Offsets in %(account)s", account=matcher.account.title|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3" role="group" aria-label="{{ A_("Toolbar") }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if matcher.is_having_matches %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-match-modal">
<i class="fa-solid fa-link"></i>
{{ A_("Match") }}
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-link"></i>
{{ A_("Match") }}
</button>
{% endif %}
</div>
{% if matcher.is_having_matches %}
<form action="{{ url_for("accounting.unmatched-offset.match", account=matcher.account) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-match-modal-label">{{ A_("Confirm Match Offsets") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<p>{{ A_("Do you really want to match the following original line items with their offsets? This cannot be undone. Please backup your database first, and review before you confirm.") }}</p>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover accounting-unmatched-offset-pair-list">
{% for pair in matcher.matched_pairs %}
<li class="list-group-item">
{{ pair.offset.description|accounting_default }}
<span class="badge bg-info">{{ pair.offset.amount|accounting_format_amount }}</span>
{{ pair.original_line_item.journal_entry.date|accounting_format_date }} &rarr; {{ pair.offset.journal_entry.date|accounting_format_date }}
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
{% if matcher.total %}
{% if matcher.is_having_matches %}
<p>{{ A_("%(matches)s unapplied original line items out of %(total)s can match with their offsets.", matches=matcher.matches, total=matcher.total) }}</p>
{% else %}
<p>{{ A_("%(total)s unapplied original line items without matching offsets.", total=matcher.total) }}</p>
{% endif %}
<p><a href="{{ url_for("accounting-report.unapplied", account=matcher.account) }}">{{ A_("Go to unapplied original line items.") }}</a></p>
{% else %}
<p>{{ A_("All original line items are fully offset.") }}</p>
{% endif %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action {% if not item.match %} list-group-item-danger {% endif %}" href="{{ url_for("accounting.journal-entry.detail", journal_entry=item.journal_entry)|accounting_append_next }}">
{{ item }}
{% if item.match %}
<div class="small">{{ A_("Can match %(item)s", item=item.match) }}</div>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -6,10 +6,10 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: mia-accounting 1.1.1\n" "Project-Id-Version: mia-accounting 1.4.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-09 01:41+0800\n" "POT-Creation-Date: 2023-04-18 09:32+0800\n"
"PO-Revision-Date: 2023-04-09 01:41+0800\n" "PO-Revision-Date: 2023-04-18 09:32+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -20,7 +20,7 @@ msgstr ""
"Generated-By: Babel 2.12.1\n" "Generated-By: Babel 2.12.1\n"
#: src/accounting/forms.py:33 #: src/accounting/forms.py:33
#: src/accounting/static/js/journal-entry-form.js:1065 #: src/accounting/static/js/journal-entry-form.js:1080
#: src/accounting/static/js/journal-entry-line-item-editor.js:411 #: src/accounting/static/js/journal-entry-line-item-editor.js:411
#: src/accounting/static/js/option-form.js:537 #: src/accounting/static/js/option-form.js:537
#: src/accounting/static/js/option-form.js:803 #: src/accounting/static/js/option-form.js:803
@ -302,11 +302,11 @@ msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
msgid "The amount must not be less than the offset total %(total)s." msgid "The amount must not be less than the offset total %(total)s."
msgstr "金額不可低於抵銷總額 %(total)s 。" msgstr "金額不可低於抵銷總額 %(total)s 。"
#: src/accounting/journal_entry/forms/line_item.py:413 #: src/accounting/journal_entry/forms/line_item.py:426
msgid "This account is not for debit line items." msgid "This account is not for debit line items."
msgstr "科目不是借方科目。" msgstr "科目不是借方科目。"
#: src/accounting/journal_entry/forms/line_item.py:465 #: src/accounting/journal_entry/forms/line_item.py:478
msgid "This account is not for credit line items." msgid "This account is not for credit line items."
msgstr "科目不是貸方科目。" msgstr "科目不是貸方科目。"
@ -354,6 +354,15 @@ msgstr "設定未異動。"
msgid "The settings are saved successfully." msgid "The settings are saved successfully."
msgstr "設定存好了。" msgstr "設定存好了。"
#: src/accounting/report/views.py:401
msgid "No more offset to match automatically."
msgstr "無法自動配對抵銷。"
#: src/accounting/report/views.py:408
#, python-format
msgid "Matched %(matches)s offsets."
msgstr "抵銷了 %(matches)s 筆。"
#: src/accounting/report/period/description.py:33 #: src/accounting/report/period/description.py:33
msgid "for all time" msgid "for all time"
msgstr "全部" msgstr "全部"
@ -423,16 +432,16 @@ msgstr "全部"
#: src/accounting/templates/accounting/journal-entry/receipt/detail.html:43 #: src/accounting/templates/accounting/journal-entry/receipt/detail.html:43
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:39 #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:39
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:55 #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:55
#: src/accounting/templates/accounting/report/balance-sheet.html:59 #: src/accounting/templates/accounting/report/balance-sheet.html:65
#: src/accounting/templates/accounting/report/balance-sheet.html:71 #: src/accounting/templates/accounting/report/balance-sheet.html:77
#: src/accounting/templates/accounting/report/balance-sheet.html:81
#: src/accounting/templates/accounting/report/balance-sheet.html:87 #: src/accounting/templates/accounting/report/balance-sheet.html:87
#: src/accounting/templates/accounting/report/balance-sheet.html:96 #: src/accounting/templates/accounting/report/balance-sheet.html:93
#: src/accounting/templates/accounting/report/balance-sheet.html:103 #: src/accounting/templates/accounting/report/balance-sheet.html:102
#: src/accounting/templates/accounting/report/balance-sheet.html:109
#: src/accounting/templates/accounting/report/income-expenses.html:81 #: src/accounting/templates/accounting/report/income-expenses.html:81
#: src/accounting/templates/accounting/report/income-statement.html:83 #: src/accounting/templates/accounting/report/income-statement.html:89
#: src/accounting/templates/accounting/report/ledger.html:82 #: src/accounting/templates/accounting/report/ledger.html:82
#: src/accounting/templates/accounting/report/trial-balance.html:74 #: src/accounting/templates/accounting/report/trial-balance.html:80
msgid "Total" msgid "Total"
msgstr "合計" msgstr "合計"
@ -444,42 +453,47 @@ msgstr "前期轉入"
#: src/accounting/report/reports/income_expenses.py:407 #: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:158 #: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/ledger.py:366 #: src/accounting/report/reports/ledger.py:366
#: src/accounting/report/reports/unapplied.py:137 #: src/accounting/report/reports/unapplied.py:148
#: src/accounting/report/reports/unmatched.py:158
#: src/accounting/templates/accounting/journal-entry/include/form.html:50 #: src/accounting/templates/accounting/journal-entry/include/form.html:50
#: src/accounting/templates/accounting/report/include/period-chooser.html:111 #: src/accounting/templates/accounting/report/include/period-chooser.html:111
#: src/accounting/templates/accounting/report/income-expenses.html:55 #: src/accounting/templates/accounting/report/income-expenses.html:55
#: src/accounting/templates/accounting/report/journal.html:53 #: src/accounting/templates/accounting/report/journal.html:53
#: src/accounting/templates/accounting/report/ledger.html:55 #: src/accounting/templates/accounting/report/ledger.html:55
#: src/accounting/templates/accounting/report/search.html:50 #: src/accounting/templates/accounting/report/search.html:50
#: src/accounting/templates/accounting/report/unapplied.html:50 #: src/accounting/templates/accounting/report/unapplied.html:52
#: src/accounting/templates/accounting/report/unmatched.html:93
msgid "Date" msgid "Date"
msgstr "日期" msgstr "日期"
#: src/accounting/report/reports/income_expenses.py:407 #: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:159 #: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/trial_balance.py:225 #: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/report/reports/unapplied_accounts.py:109 #: src/accounting/report/reports/unapplied_accounts.py:122
#: src/accounting/report/reports/unmatched_accounts.py:122
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90 #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
#: src/accounting/templates/accounting/report/income-expenses.html:56 #: src/accounting/templates/accounting/report/income-expenses.html:56
#: src/accounting/templates/accounting/report/journal.html:55 #: src/accounting/templates/accounting/report/journal.html:55
#: src/accounting/templates/accounting/report/search.html:52 #: src/accounting/templates/accounting/report/search.html:52
#: src/accounting/templates/accounting/report/trial-balance.html:55 #: src/accounting/templates/accounting/report/trial-balance.html:61
msgid "Account" msgid "Account"
msgstr "科目" msgstr "科目"
#: src/accounting/report/reports/income_expenses.py:408 #: src/accounting/report/reports/income_expenses.py:408
#: src/accounting/report/reports/journal.py:159 #: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/ledger.py:366 #: src/accounting/report/reports/ledger.py:366
#: src/accounting/report/reports/unapplied.py:138 #: src/accounting/report/reports/unapplied.py:149
#: src/accounting/report/reports/unmatched.py:159
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49
#: src/accounting/templates/accounting/report/income-expenses.html:57 #: src/accounting/templates/accounting/report/income-expenses.html:57
#: src/accounting/templates/accounting/report/journal.html:56 #: src/accounting/templates/accounting/report/journal.html:56
#: src/accounting/templates/accounting/report/ledger.html:56 #: src/accounting/templates/accounting/report/ledger.html:56
#: src/accounting/templates/accounting/report/search.html:53 #: src/accounting/templates/accounting/report/search.html:53
#: src/accounting/templates/accounting/report/unapplied.html:52 #: src/accounting/templates/accounting/report/unapplied.html:53
#: src/accounting/templates/accounting/report/unmatched.html:94
msgid "Description" msgid "Description"
msgstr "摘要" msgstr "摘要"
@ -495,8 +509,10 @@ msgstr "支出"
#: src/accounting/report/reports/income_expenses.py:409 #: src/accounting/report/reports/income_expenses.py:409
#: src/accounting/report/reports/ledger.py:368 #: src/accounting/report/reports/ledger.py:368
#: src/accounting/report/reports/unmatched.py:160
#: src/accounting/templates/accounting/report/income-expenses.html:60 #: src/accounting/templates/accounting/report/income-expenses.html:60
#: src/accounting/templates/accounting/report/ledger.html:60 #: src/accounting/templates/accounting/report/ledger.html:60
#: src/accounting/templates/accounting/report/unmatched.html:97
msgid "Balance" msgid "Balance"
msgstr "餘額" msgstr "餘額"
@ -533,64 +549,88 @@ msgid "net income or loss for current period"
msgstr "本期損益" msgstr "本期損益"
#: src/accounting/report/reports/income_statement.py:301 #: src/accounting/report/reports/income_statement.py:301
#: src/accounting/report/reports/unapplied.py:138 #: src/accounting/report/reports/unapplied.py:149
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65
#: src/accounting/templates/accounting/report/income-statement.html:55 #: src/accounting/templates/accounting/report/income-statement.html:61
#: src/accounting/templates/accounting/report/unapplied.html:53 #: src/accounting/templates/accounting/report/unapplied.html:54
msgid "Amount" msgid "Amount"
msgstr "金額" msgstr "金額"
#: src/accounting/report/reports/journal.py:158 #: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/unapplied.py:137 #: src/accounting/report/reports/unapplied.py:148
#: src/accounting/report/reports/unmatched.py:158
#: src/accounting/templates/accounting/journal-entry/include/form-currency.html:33 #: src/accounting/templates/accounting/journal-entry/include/form-currency.html:33
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:73 #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:73
#: src/accounting/templates/accounting/report/journal.html:54 #: src/accounting/templates/accounting/report/journal.html:54
#: src/accounting/templates/accounting/report/search.html:51 #: src/accounting/templates/accounting/report/search.html:51
#: src/accounting/templates/accounting/report/unapplied.html:51
msgid "Currency" msgid "Currency"
msgstr "貨幣" msgstr "貨幣"
#: src/accounting/report/reports/journal.py:160 #: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367 #: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/trial_balance.py:225 #: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/report/reports/unmatched.py:159
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33 #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:30 #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:30
#: src/accounting/templates/accounting/report/journal.html:57 #: src/accounting/templates/accounting/report/journal.html:57
#: src/accounting/templates/accounting/report/ledger.html:57 #: src/accounting/templates/accounting/report/ledger.html:57
#: src/accounting/templates/accounting/report/search.html:54 #: src/accounting/templates/accounting/report/search.html:54
#: src/accounting/templates/accounting/report/trial-balance.html:56 #: src/accounting/templates/accounting/report/trial-balance.html:62
#: src/accounting/templates/accounting/report/unmatched.html:95
msgid "Debit" msgid "Debit"
msgstr "借方" msgstr "借方"
#: src/accounting/report/reports/journal.py:160 #: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367 #: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/trial_balance.py:226 #: src/accounting/report/reports/trial_balance.py:226
#: src/accounting/report/reports/unmatched.py:160
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49 #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:41 #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:41
#: src/accounting/templates/accounting/report/journal.html:58 #: src/accounting/templates/accounting/report/journal.html:58
#: src/accounting/templates/accounting/report/ledger.html:58 #: src/accounting/templates/accounting/report/ledger.html:58
#: src/accounting/templates/accounting/report/search.html:55 #: src/accounting/templates/accounting/report/search.html:55
#: src/accounting/templates/accounting/report/trial-balance.html:57 #: src/accounting/templates/accounting/report/trial-balance.html:63
#: src/accounting/templates/accounting/report/unmatched.html:96
msgid "Credit" msgid "Credit"
msgstr "貸方" msgstr "貸方"
#: src/accounting/report/reports/unapplied.py:121 #: src/accounting/report/reports/unapplied.py:132
#: src/accounting/report/reports/unapplied_accounts.py:93 #: src/accounting/report/reports/unapplied_accounts.py:107
#: src/accounting/report/reports/unmatched.py:142
#: src/accounting/report/reports/unmatched_accounts.py:107
#: src/accounting/templates/accounting/include/nav.html:39 #: src/accounting/templates/accounting/include/nav.html:39
msgid "Accounts" msgid "Accounts"
msgstr "科目" msgstr "科目"
#: src/accounting/report/reports/unapplied.py:139 #: src/accounting/report/reports/unapplied.py:150
#: src/accounting/templates/accounting/report/unapplied.html:54 #: src/accounting/templates/accounting/report/unapplied.html:55
msgid "Net Balance" msgid "Net Balance"
msgstr "淨額" msgstr "淨額"
#: src/accounting/report/reports/unapplied_accounts.py:109 #: src/accounting/report/reports/unapplied_accounts.py:122
#: src/accounting/templates/accounting/report/unapplied-accounts.html:47 #: src/accounting/report/reports/unmatched_accounts.py:122
#: src/accounting/templates/accounting/report/unapplied-accounts.html:59
#: src/accounting/templates/accounting/report/unmatched-accounts.html:59
msgid "Count" msgid "Count"
msgstr "數量" msgstr "數量"
#: src/accounting/report/utils/report_chooser.py:82 #: src/accounting/report/utils/offset_matcher.py:163
msgid "There is no unmatched offset."
msgstr "沒有遺漏的抵銷分錄"
#: src/accounting/report/utils/offset_matcher.py:167
#, python-format
msgid "%(total)s unmatched offsets without original items."
msgstr "%(total)s 筆遺漏的抵銷分錄無法自動抵銷。"
#: src/accounting/report/utils/offset_matcher.py:172
#, python-format
msgid ""
"%(matches)s unmatched offsets out of %(total)s can match with their "
"original items."
msgstr "%(total)s 筆遺漏的抵銷分錄中,可配對抵銷掉 %(matches)s 筆。"
#: src/accounting/report/utils/report_chooser.py:86
#: src/accounting/templates/accounting/account/include/form.html:98 #: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40 #: src/accounting/templates/accounting/account/list.html:40
#: src/accounting/templates/accounting/base-account/list.html:34 #: src/accounting/templates/accounting/base-account/list.html:34
@ -606,114 +646,119 @@ msgstr "數量"
msgid "Search" msgid "Search"
msgstr "搜尋" msgstr "搜尋"
#: src/accounting/report/utils/report_chooser.py:93 #: src/accounting/report/utils/report_chooser.py:97
msgid "Income and Expenses Log" msgid "Income and Expenses Log"
msgstr "收支帳" msgstr "收支帳"
#: src/accounting/report/utils/report_chooser.py:106 #: src/accounting/report/utils/report_chooser.py:110
msgid "Ledger" msgid "Ledger"
msgstr "分類帳" msgstr "分類帳"
#: src/accounting/report/utils/report_chooser.py:118 #: src/accounting/report/utils/report_chooser.py:122
msgid "Journal" msgid "Journal"
msgstr "日記簿" msgstr "日記簿"
#: src/accounting/report/utils/report_chooser.py:128 #: src/accounting/report/utils/report_chooser.py:132
msgid "Trial Balance" msgid "Trial Balance"
msgstr "試算表" msgstr "試算表"
#: src/accounting/report/utils/report_chooser.py:139 #: src/accounting/report/utils/report_chooser.py:143
msgid "Income Statement" msgid "Income Statement"
msgstr "損益表" msgstr "損益表"
#: src/accounting/report/utils/report_chooser.py:150 #: src/accounting/report/utils/report_chooser.py:154
msgid "Balance Sheet" msgid "Balance Sheet"
msgstr "資產負債表" msgstr "資產負債表"
#: src/accounting/report/utils/report_chooser.py:163
#: src/accounting/report/utils/report_chooser.py:167 #: src/accounting/report/utils/report_chooser.py:167
msgid "Unapplied Original Line Items" #: src/accounting/report/utils/report_chooser.py:171
msgstr "未抵銷原始分錄" msgid "Unapplied Items"
msgstr "未抵銷項目"
#: src/accounting/report/utils/report_chooser.py:184
#: src/accounting/report/utils/report_chooser.py:188
msgid "Unmatched Offsets"
msgstr "遺漏的抵銷項目"
#: src/accounting/static/js/account-form.js:206 #: src/accounting/static/js/account-form.js:206
msgid "Please fill in the title." msgid "Please fill in the title."
msgstr "請填上標題。" msgstr "請填上標題。"
#: src/accounting/static/js/description-editor.js:951 #: src/accounting/static/js/description-editor.js:952
#: src/accounting/static/js/description-editor.js:1129 #: src/accounting/static/js/description-editor.js:1130
msgid "Please fill in the tag." msgid "Please fill in the tag."
msgstr "請填上標籤。" msgstr "請填上標籤。"
#: src/accounting/static/js/description-editor.js:961 #: src/accounting/static/js/description-editor.js:962
#: src/accounting/static/js/description-editor.js:1149 #: src/accounting/static/js/description-editor.js:1150
msgid "Please fill in the origin." msgid "Please fill in the origin."
msgstr "請填上起點。" msgstr "請填上起點。"
#: src/accounting/static/js/description-editor.js:971 #: src/accounting/static/js/description-editor.js:972
#: src/accounting/static/js/description-editor.js:1159 #: src/accounting/static/js/description-editor.js:1160
msgid "Please fill in the destination." msgid "Please fill in the destination."
msgstr "請填上終點。" msgstr "請填上終點。"
#: src/accounting/static/js/description-editor.js:1139 #: src/accounting/static/js/description-editor.js:1140
msgid "Please fill in the route." msgid "Please fill in the route."
msgstr "請填上路線名稱。" msgstr "請填上路線名稱。"
#: src/accounting/static/js/description-editor.js:1192 #: src/accounting/static/js/description-editor.js:1193
msgid "January" msgid "January"
msgstr "一月" msgstr "一月"
#: src/accounting/static/js/description-editor.js:1192 #: src/accounting/static/js/description-editor.js:1193
msgid "February" msgid "February"
msgstr "二月" msgstr "二月"
#: src/accounting/static/js/description-editor.js:1192 #: src/accounting/static/js/description-editor.js:1193
msgid "March" msgid "March"
msgstr "三月" msgstr "三月"
#: src/accounting/static/js/description-editor.js:1192 #: src/accounting/static/js/description-editor.js:1193
msgid "April" msgid "April"
msgstr "四月" msgstr "四月"
#: src/accounting/static/js/description-editor.js:1193 #: src/accounting/static/js/description-editor.js:1194
msgid "May" msgid "May"
msgstr "五月" msgstr "五月"
#: src/accounting/static/js/description-editor.js:1193 #: src/accounting/static/js/description-editor.js:1194
msgid "June" msgid "June"
msgstr "六月" msgstr "六月"
#: src/accounting/static/js/description-editor.js:1193 #: src/accounting/static/js/description-editor.js:1194
msgid "July" msgid "July"
msgstr "七月" msgstr "七月"
#: src/accounting/static/js/description-editor.js:1193 #: src/accounting/static/js/description-editor.js:1194
msgid "August" msgid "August"
msgstr "八月" msgstr "八月"
#: src/accounting/static/js/description-editor.js:1194 #: src/accounting/static/js/description-editor.js:1195
msgid "September" msgid "September"
msgstr "九月" msgstr "九月"
#: src/accounting/static/js/description-editor.js:1194 #: src/accounting/static/js/description-editor.js:1195
msgid "October" msgid "October"
msgstr "十月" msgstr "十月"
#: src/accounting/static/js/description-editor.js:1194 #: src/accounting/static/js/description-editor.js:1195
msgid "November" msgid "November"
msgstr "十一月" msgstr "十一月"
#: src/accounting/static/js/description-editor.js:1194 #: src/accounting/static/js/description-editor.js:1195
msgid "December" msgid "December"
msgstr "十二月" msgstr "十二月"
#: src/accounting/static/js/journal-entry-form.js:1070 #: src/accounting/static/js/journal-entry-form.js:1085
#: src/accounting/static/js/journal-entry-line-item-editor.js:430 #: src/accounting/static/js/journal-entry-line-item-editor.js:430
msgid "Please fill in the amount." msgid "Please fill in the amount."
msgstr "請填上金額。" msgstr "請填上金額。"
#: src/accounting/static/js/journal-entry-form.js:1092 #: src/accounting/static/js/journal-entry-form.js:1107
#: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:34 #: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:37
#: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:38 #: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:41
#, python-format #, python-format
msgid "Offset %(item)s" msgid "Offset %(item)s"
msgstr "抵銷 %(item)s" msgstr "抵銷 %(item)s"
@ -756,7 +801,6 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/journal-entry/include/form.html:38 #: src/accounting/templates/accounting/journal-entry/include/form.html:38
#: src/accounting/templates/accounting/journal-entry/order.html:36 #: src/accounting/templates/accounting/journal-entry/order.html:36
#: src/accounting/templates/accounting/option/form.html:36 #: src/accounting/templates/accounting/option/form.html:36
#: src/accounting/templates/accounting/unmatched-offset/list.html:31
msgid "Back" msgid "Back"
msgstr "回上頁" msgstr "回上頁"
@ -797,7 +841,7 @@ msgstr "確認刪除科目"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
#: src/accounting/templates/accounting/report/include/period-chooser.html:27 #: src/accounting/templates/accounting/report/include/period-chooser.html:27
#: src/accounting/templates/accounting/report/include/search-modal.html:28 #: src/accounting/templates/accounting/report/include/search-modal.html:28
#: src/accounting/templates/accounting/unmatched-offset/list.html:54 #: src/accounting/templates/accounting/report/unmatched.html:58
msgid "Close" msgid "Close"
msgstr "關閉" msgstr "關閉"
@ -815,7 +859,7 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48 #: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
#: src/accounting/templates/accounting/report/include/search-modal.html:37 #: src/accounting/templates/accounting/report/include/search-modal.html:37
#: src/accounting/templates/accounting/unmatched-offset/list.html:70 #: src/accounting/templates/accounting/report/unmatched.html:74
msgid "Cancel" msgid "Cancel"
msgstr "取消" msgstr "取消"
@ -823,7 +867,7 @@ msgstr "取消"
#: src/accounting/templates/accounting/currency/detail.html:80 #: src/accounting/templates/accounting/currency/detail.html:80
#: src/accounting/templates/accounting/journal-entry/include/detail.html:85 #: src/accounting/templates/accounting/journal-entry/include/detail.html:85
#: src/accounting/templates/accounting/report/include/period-chooser.html:141 #: src/accounting/templates/accounting/report/include/period-chooser.html:141
#: src/accounting/templates/accounting/unmatched-offset/list.html:71 #: src/accounting/templates/accounting/report/unmatched.html:75
msgid "Confirm" msgid "Confirm"
msgstr "確定" msgstr "確定"
@ -871,22 +915,22 @@ msgstr "新增"
#: src/accounting/templates/accounting/base-account/list.html:51 #: src/accounting/templates/accounting/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:65 #: src/accounting/templates/accounting/currency/list.html:65
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:46 #: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:46
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:51 #: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:58
#: src/accounting/templates/accounting/journal-entry/order.html:82 #: src/accounting/templates/accounting/journal-entry/order.html:82
#: src/accounting/templates/accounting/option/detail.html:67 #: src/accounting/templates/accounting/option/detail.html:67
#: src/accounting/templates/accounting/option/detail.html:83 #: src/accounting/templates/accounting/option/detail.html:83
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:45 #: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:45
#: src/accounting/templates/accounting/report/balance-sheet.html:110 #: src/accounting/templates/accounting/report/balance-sheet.html:116
#: src/accounting/templates/accounting/report/income-expenses.html:113 #: src/accounting/templates/accounting/report/income-expenses.html:113
#: src/accounting/templates/accounting/report/income-statement.html:96 #: src/accounting/templates/accounting/report/income-statement.html:102
#: src/accounting/templates/accounting/report/journal.html:103 #: src/accounting/templates/accounting/report/journal.html:103
#: src/accounting/templates/accounting/report/ledger.html:116 #: src/accounting/templates/accounting/report/ledger.html:116
#: src/accounting/templates/accounting/report/search.html:100 #: src/accounting/templates/accounting/report/search.html:100
#: src/accounting/templates/accounting/report/trial-balance.html:82 #: src/accounting/templates/accounting/report/trial-balance.html:88
#: src/accounting/templates/accounting/report/unapplied-accounts.html:61 #: src/accounting/templates/accounting/report/unapplied-accounts.html:76
#: src/accounting/templates/accounting/report/unapplied.html:98 #: src/accounting/templates/accounting/report/unapplied.html:90
#: src/accounting/templates/accounting/unmatched-offset/dashboard.html:37 #: src/accounting/templates/accounting/report/unmatched-accounts.html:76
#: src/accounting/templates/accounting/unmatched-offset/list.html:104 #: src/accounting/templates/accounting/report/unmatched.html:147
msgid "There is no data." msgid "There is no data."
msgstr "沒有資料。" msgstr "沒有資料。"
@ -984,12 +1028,7 @@ msgstr "基本科目"
msgid "Currencies" msgid "Currencies"
msgstr "貨幣" msgstr "貨幣"
#: src/accounting/templates/accounting/include/nav.html:57 #: src/accounting/templates/accounting/include/nav.html:58
#: src/accounting/templates/accounting/unmatched-offset/dashboard.html:24
msgid "Unmatched Offsets"
msgstr "遺漏的抵銷分錄"
#: src/accounting/templates/accounting/include/nav.html:64
#: src/accounting/templates/accounting/option/detail.html:24 #: src/accounting/templates/accounting/option/detail.html:24
#: src/accounting/templates/accounting/option/detail.html:41 #: src/accounting/templates/accounting/option/detail.html:41
#: src/accounting/templates/accounting/option/form.html:29 #: src/accounting/templates/accounting/option/form.html:29
@ -1088,23 +1127,23 @@ msgstr "路線"
msgid "The Number of Items" msgid "The Number of Items"
msgstr "數量" msgstr "數量"
#: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:42 #: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:45
#: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:43 #: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:46
msgid "Offsets" msgid "Offsets"
msgstr "抵銷" msgstr "抵銷"
#: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:55 #: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:58
#: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:54 #: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:57
msgid "Net balance" msgid "Net balance"
msgstr "淨額" msgstr "淨額"
#: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:60 #: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:63
#: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:51 #: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:54
msgid "Fully offset" msgid "Fully offset"
msgstr "全部抵銷" msgstr "全部抵銷"
#: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:65 #: src/accounting/templates/accounting/journal-entry/include/detail-line-items.html:68
#: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:59 #: src/accounting/templates/accounting/journal-entry/include/form-line-item.html:62
msgid "Unmatched" msgid "Unmatched"
msgstr "未抵銷" msgstr "未抵銷"
@ -1217,18 +1256,35 @@ msgid "Water bill for {last_bimonthly_name}"
msgstr "水費{last_bimonthly_number}月" msgstr "水費{last_bimonthly_number}月"
#: src/accounting/templates/accounting/report/balance-sheet.html:29 #: src/accounting/templates/accounting/report/balance-sheet.html:29
#: src/accounting/templates/accounting/report/balance-sheet.html:49 #: src/accounting/templates/accounting/report/balance-sheet.html:51
#, python-format
msgid "Balance Sheet %(period)s"
msgstr "%(period)s資產負債表"
#: src/accounting/templates/accounting/report/balance-sheet.html:29
#: src/accounting/templates/accounting/report/balance-sheet.html:53
#, python-format #, python-format
msgid "Balance Sheet of %(currency)s %(period)s" msgid "Balance Sheet of %(currency)s %(period)s"
msgstr "%(period)s%(currency)s資產負債表" msgstr "%(period)s%(currency)s資產負債表"
#: src/accounting/templates/accounting/report/income-expenses.html:29
#, python-format
msgid "Income and Expenses Log of %(account)s %(period)s"
msgstr "%(period)s%(account)s收支帳"
#: src/accounting/templates/accounting/report/income-expenses.html:29 #: src/accounting/templates/accounting/report/income-expenses.html:29
#, python-format #, python-format
msgid "Income and Expenses Log of %(account)s in %(currency)s %(period)s" msgid "Income and Expenses Log of %(account)s in %(currency)s %(period)s"
msgstr "%(period)s%(currency)s%(account)s收支帳" msgstr "%(period)s%(currency)s%(account)s收支帳"
#: src/accounting/templates/accounting/report/income-statement.html:29 #: src/accounting/templates/accounting/report/income-statement.html:29
#: src/accounting/templates/accounting/report/income-statement.html:49 #: src/accounting/templates/accounting/report/income-statement.html:51
#, python-format
msgid "Income Statement %(period)s"
msgstr "%(period)s損益表"
#: src/accounting/templates/accounting/report/income-statement.html:29
#: src/accounting/templates/accounting/report/income-statement.html:53
#, python-format #, python-format
msgid "Income Statement of %(currency)s %(period)s" msgid "Income Statement of %(currency)s %(period)s"
msgstr "%(period)s%(currency)s損益表" msgstr "%(period)s%(currency)s損益表"
@ -1238,31 +1294,89 @@ msgstr "%(period)s%(currency)s損益表"
msgid "Journal %(period)s" msgid "Journal %(period)s"
msgstr "%(period)s日記簿" msgstr "%(period)s日記簿"
#: src/accounting/templates/accounting/report/ledger.html:29
#, python-format
msgid "Ledger of %(account)s %(period)s"
msgstr "%(period)s%(account)s分類帳"
#: src/accounting/templates/accounting/report/ledger.html:29 #: src/accounting/templates/accounting/report/ledger.html:29
#, python-format #, python-format
msgid "Ledger of %(account)s in %(currency)s %(period)s" msgid "Ledger of %(account)s in %(currency)s %(period)s"
msgstr "%(period)s%(currency)s%(account)s分類帳" msgstr "%(period)s%(currency)s%(account)s分類帳"
#: src/accounting/templates/accounting/report/trial-balance.html:29 #: src/accounting/templates/accounting/report/trial-balance.html:29
#: src/accounting/templates/accounting/report/trial-balance.html:49 #: src/accounting/templates/accounting/report/trial-balance.html:51
#, python-format
msgid "Trial Balance %(period)s"
msgstr "%(period)s試算表"
#: src/accounting/templates/accounting/report/trial-balance.html:29
#: src/accounting/templates/accounting/report/trial-balance.html:53
#, python-format #, python-format
msgid "Trial Balance of %(currency)s %(period)s" msgid "Trial Balance of %(currency)s %(period)s"
msgstr "%(period)s%(currency)s試算表" msgstr "%(period)s%(currency)s試算表"
#: src/accounting/templates/accounting/report/unapplied-accounts.html:24 #: src/accounting/templates/accounting/report/unapplied-accounts.html:29
#: src/accounting/templates/accounting/report/unapplied-accounts.html:41 #: src/accounting/templates/accounting/report/unapplied-accounts.html:49
msgid "Accounts with Unapplied Original Line Items" msgid "Accounts with Unapplied Items"
msgstr "未抵銷原始分錄的科目" msgstr "未抵銷項目的科目"
#: src/accounting/templates/accounting/report/unapplied.html:28 #: src/accounting/templates/accounting/report/unapplied-accounts.html:29
#: src/accounting/templates/accounting/report/unapplied-accounts.html:51
#, python-format #, python-format
msgid "Unapplied Original Line Items of %(account)s" msgid "Accounts with Unapplied Items in %(currency)s"
msgstr "%(account)s未抵銷原始分錄" msgstr "%(currency)s未抵銷項目的科目"
#: src/accounting/templates/accounting/report/unapplied.html:65 #: src/accounting/templates/accounting/report/unapplied.html:29
#, python-format #, python-format
msgid "Can match %(offset)s" msgid "Unapplied Items of %(account)s"
msgstr "可抵銷 %(offset)s" msgstr "%(account)s未抵銷項目"
#: src/accounting/templates/accounting/report/unapplied.html:29
#, python-format
msgid "Unapplied Items of %(account)s in %(currency)s"
msgstr "%(currency)s%(account)s未抵銷項目"
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
#: src/accounting/templates/accounting/report/unmatched-accounts.html:49
msgid "Accounts with Unmatched Offsets"
msgstr "含遺漏抵銷項目的科目"
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
#: src/accounting/templates/accounting/report/unmatched-accounts.html:51
#, python-format
msgid "Accounts with Unmatched Offsets in %(currency)s"
msgstr "%(currency)s含遺漏抵銷項目的科目"
#: src/accounting/templates/accounting/report/unmatched.html:29
#, python-format
msgid "Unmatched Offsets of %(account)s"
msgstr "%(account)s遺漏的抵銷項目"
#: src/accounting/templates/accounting/report/unmatched.html:29
#, python-format
msgid "Unmatched Offsets of %(account)s in %(currency)s"
msgstr "%(currency)s%(account)s遺漏的抵銷項目"
#: src/accounting/templates/accounting/report/unmatched.html:47
msgid "Match"
msgstr "抵銷"
#: src/accounting/templates/accounting/report/unmatched.html:57
msgid "Confirm Match Offsets"
msgstr "確認抵銷"
#: src/accounting/templates/accounting/report/unmatched.html:61
msgid ""
"Do you really want to match the following original line items with their "
"offsets? This cannot be undone. Please backup your database first, and "
"review before you confirm."
msgstr "你確定要抵銷下列原始分錄與抵銷分錄嗎?結果無法復原。請先備份資料庫,並仔細核對抵銷分錄是否正確。"
#: src/accounting/templates/accounting/report/unmatched.html:107
#, python-format
msgid "Can match %(item)s"
msgstr "可抵銷 %(item)s"
#: src/accounting/templates/accounting/report/include/period-chooser.html:26 #: src/accounting/templates/accounting/report/include/period-chooser.html:26
msgid "Period Chooser" msgid "Period Chooser"
@ -1297,65 +1411,6 @@ msgstr "期間"
msgid "Download" msgid "Download"
msgstr "下載" msgstr "下載"
#: src/accounting/templates/accounting/unmatched-offset/list.html:24
#, python-format
msgid "Unmatched Offsets in %(account)s"
msgstr "%(account)s遺漏的抵銷分錄"
#: src/accounting/templates/accounting/unmatched-offset/list.html:28
msgid "Toolbar"
msgstr "工具列"
#: src/accounting/templates/accounting/unmatched-offset/list.html:36
#: src/accounting/templates/accounting/unmatched-offset/list.html:41
msgid "Match"
msgstr "抵銷"
#: src/accounting/templates/accounting/unmatched-offset/list.html:53
msgid "Confirm Match Offsets"
msgstr "確認抵銷"
#: src/accounting/templates/accounting/unmatched-offset/list.html:57
msgid ""
"Do you really want to match the following original line items with their "
"offsets? This cannot be undone. Please backup your database first, and "
"review before you confirm."
msgstr "你確定要抵銷下列原始分錄與抵銷分錄嗎?結果無法復原。請先備份資料庫,並仔細核對抵銷分錄是否正確。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:81
#, python-format
msgid ""
"%(matches)s unapplied original line items out of %(total)s can match with"
" their offsets."
msgstr "%(total)s 筆未抵銷原始分錄中,可配對抵銷掉 %(matches)s 筆。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:83
#, python-format
msgid "%(total)s unapplied original line items without matching offsets."
msgstr "%(total)s 筆未抵銷原始分錄,無法自動配對抵銷。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:85
msgid "Go to unapplied original line items."
msgstr "查閱未抵銷原始分錄。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:87
msgid "All original line items are fully offset."
msgstr "原始分錄已全部抵銷。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:98
#, python-format
msgid "Can match %(item)s"
msgstr "可抵銷 %(item)s"
#: src/accounting/unmatched_offset/views.py:71
msgid "No more offset to match automatically."
msgstr "無法自動配對抵銷。"
#: src/accounting/unmatched_offset/views.py:77
#, python-format
msgid "Matches %(matches)s from %(total)s unapplied line items."
msgstr "%(total)s 筆未抵銷原始分錄中,配對抵銷掉 %(matches)s 筆。"
#: src/accounting/utils/current_account.py:65 #: src/accounting/utils/current_account.py:65
msgid "current assets and liabilities" msgid "current assets and liabilities"
msgstr "流動資產與負債" msgstr "流動資產與負債"

View File

@ -1,30 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unmatched offset management.
"""
from flask import Blueprint
def init_app(bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .views import bp as unmatched_offset_bp
bp.register_blueprint(unmatched_offset_bp, url_prefix="/unmatched-offsets")

View File

@ -1,81 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the unmatched offset management.
"""
from flask import Blueprint, render_template, redirect, url_for, flash
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem, Account
from accounting.utils.cast import s
from accounting.utils.offset_matcher import OffsetMatcher
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_edit
from .queries import get_accounts_with_unmatched_offsets
bp: Blueprint = Blueprint("unmatched-offset", __name__)
"""The view blueprint for the unmatched offset management."""
@bp.get("", endpoint="dashboard")
@has_permission(can_edit)
def show_offset_dashboard() -> str:
"""Shows the dashboard about offsets.
:return: The dashboard about offsets.
"""
return render_template("accounting/unmatched-offset/dashboard.html",
list=get_accounts_with_unmatched_offsets())
@bp.get("<needOffsetAccount:account>", endpoint="list")
@has_permission(can_edit)
def show_unmatched_offsets(account: Account) -> str:
"""Shows the unmatched offsets in an account.
:return: The unmatched offsets in an account.
"""
matcher: OffsetMatcher = OffsetMatcher(account)
pagination: Pagination \
= Pagination[JournalEntryLineItem](matcher.unmatched_offsets,
is_reversed=True)
return render_template("accounting/unmatched-offset/list.html",
matcher=matcher,
list=pagination.list, pagination=pagination)
@bp.post("<needOffsetAccount:account>", endpoint="match")
@has_permission(can_edit)
def match_offsets(account: Account) -> redirect:
"""Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets.
"""
matcher: OffsetMatcher = OffsetMatcher(account)
if not matcher.is_having_matches:
flash(s(lazy_gettext("No more offset to match automatically.")),
"success")
return redirect(url_for("accounting.unmatched-offset.list",
account=account))
matcher.match()
db.session.commit()
flash(s(lazy_gettext(
"Matches %(matches)s from %(total)s unapplied line items.",
matches=matcher.matches, total=matcher.total)), "success")
return redirect(url_for("accounting.unmatched-offset.list",
account=account))

View File

@ -22,18 +22,6 @@ This module should not import any other module from the application.
""" """
import typing as t import typing as t
import sqlalchemy as sa
def be(expression: t.Any) -> sa.BinaryExpression:
"""Casts the SQLAlchemy binary expression to the binary expression type.
:param expression: The binary expression.
:return: The binary expression itself.
"""
assert isinstance(expression, sa.BinaryExpression)
return expression
def s(message: t.Any) -> str: def s(message: t.Any) -> str:
"""Casts the LazyString message to the string type. """Casts the LazyString message to the string type.

View File

@ -1,129 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the unmatched offset management.
"""
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.unapplied import get_unapplied_original_line_items
class OffsetPair:
"""A pair of an original line item and its offset."""
def __init__(self, original_line_item: JournalEntryLineItem,
offset: JournalEntryLineItem):
"""Constructs a pair of an original line item and its offset.
:param original_line_item: The original line item.
:param offset: The offset.
"""
self.original_line_item: JournalEntryLineItem = original_line_item
"""The original line item."""
self.offset: JournalEntryLineItem = offset
"""The offset."""
class OffsetMatcher:
"""The offset matcher."""
def __init__(self, account: Account):
"""Constructs the offset matcher.
:param account: The account.
"""
self.account: Account = account
"""The account."""
self.matched_pairs: list[OffsetPair] = []
"""A list of matched pairs."""
self.is_having_matches: bool = False
"""Whether there is any matches."""
self.total: int = 0
"""The total number of unapplied debits or credits."""
self.unapplied: list[JournalEntryLineItem] = []
"""The unapplied debits or credits."""
self.unmatched_offsets: list[JournalEntryLineItem] = []
"""The unmatched offsets."""
self.__find_matches()
def __find_matches(self) -> None:
"""Finds the matched original line items and their offsets.
:return: None.
"""
self.unapplied: list[JournalEntryLineItem] \
= get_unapplied_original_line_items(self.account)
self.total = len(self.unapplied)
if self.total == 0:
self.is_having_matches = False
return
self.unmatched_offsets = self.__get_unmatched_offsets()
remains: list[JournalEntryLineItem] = self.unmatched_offsets.copy()
for original_item in self.unapplied:
offset_candidates: list[JournalEntryLineItem] \
= [x for x in remains
if (x.journal_entry.date > original_item.journal_entry.date
or (x.journal_entry.date
== original_item.journal_entry.date
and x.journal_entry.no
> original_item.journal_entry.no))
and x.currency_code == original_item.currency_code
and x.description == original_item.description
and x.amount == original_item.net_balance]
if len(offset_candidates) == 0:
continue
self.matched_pairs.append(
OffsetPair(original_item, offset_candidates[0]))
original_item.match = offset_candidates[0]
offset_candidates[0].match = original_item
remains.remove(offset_candidates[0])
self.is_having_matches = len(self.matched_pairs) > 0
def __get_unmatched_offsets(self) -> list[JournalEntryLineItem]:
"""Returns the unmatched offsets of an account.
:return: The unmatched offsets of the account.
"""
return JournalEntryLineItem.query.join(Account).join(JournalEntry)\
.filter(Account.id == self.account.id,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
@property
def matches(self) -> int:
"""Returns the number of matches.
:return: The number of matches.
"""
return len(self.matched_pairs)
def match(self) -> None:
"""Matches the original line items with offsets.
:return: None.
"""
for pair in self.matched_pairs:
pair.offset.original_line_item_id = pair.original_line_item.id

View File

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

View File

@ -129,5 +129,5 @@ def __update_file_rev_date(file: Path) -> None:
main.add_command(babel_extract) main.add_command(babel_extract)
main.add_command(babel_compile) main.add_command(babel_compile)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@ -129,5 +129,5 @@ def __update_file_rev_date(file: Path) -> None:
main.add_command(babel_extract) main.add_command(babel_extract)
main.add_command(babel_compile) main.add_command(babel_compile)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@ -17,8 +17,8 @@
"""The test for the account management. """The test for the account management.
""" """
import datetime as dt
import unittest import unittest
from datetime import timedelta, date
import httpx import httpx
from flask import Flask from flask import Flask
@ -461,7 +461,7 @@ class AccountTestCase(unittest.TestCase):
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account) self.assertIsNotNone(account)
account.created_at \ account.created_at \
= account.created_at - timedelta(seconds=5) = account.created_at - dt.timedelta(seconds=5)
account.updated_at = account.created_at account.updated_at = account.created_at
db.session.commit() db.session.commit()
@ -592,7 +592,7 @@ class AccountTestCase(unittest.TestCase):
add_journal_entry(self.client, add_journal_entry(self.client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": "USD", "currency-1-code": "USD",
"currency-1-credit-1-account_code": BANK.code, "currency-1-credit-1-account_code": BANK.code,
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})

View File

@ -17,8 +17,8 @@
"""The test for the currency management. """The test for the currency management.
""" """
import datetime as dt
import unittest import unittest
from datetime import timedelta, date
import httpx import httpx
from flask import Flask from flask import Flask
@ -384,7 +384,7 @@ class CurrencyTestCase(unittest.TestCase):
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency) self.assertIsNotNone(currency)
currency.created_at \ currency.created_at \
= currency.created_at - timedelta(seconds=5) = currency.created_at - dt.timedelta(seconds=5)
currency.updated_at = currency.created_at currency.updated_at = currency.created_at
db.session.commit() db.session.commit()
@ -534,7 +534,7 @@ class CurrencyTestCase(unittest.TestCase):
add_journal_entry(self.client, add_journal_entry(self.client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": EUR.code, "currency-1-code": EUR.code,
"currency-1-credit-1-account_code": "1111-001", "currency-1-credit-1-account_code": "1111-001",
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})

View File

@ -17,8 +17,8 @@
"""The test for the description editor. """The test for the description editor.
""" """
import datetime as dt
import unittest import unittest
from datetime import date
from flask import Flask from flask import Flask
@ -149,7 +149,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:return: A list of the form data. :return: A list of the form data.
""" """
journal_entry_date: str = date.today().isoformat() journal_entry_date: str = dt.date.today().isoformat()
return [{"csrf_token": csrf_token, return [{"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": journal_entry_date, "date": journal_entry_date,

View File

@ -17,8 +17,8 @@
"""The test for the journal entry management. """The test for the journal entry management.
""" """
import datetime as dt
import unittest import unittest
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
import httpx import httpx
@ -500,7 +500,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
self.assertIsNotNone(journal_entry) self.assertIsNotNone(journal_entry)
journal_entry.created_at \ journal_entry.created_at \
= journal_entry.created_at - timedelta(seconds=5) = journal_entry.created_at - dt.timedelta(seconds=5)
journal_entry.updated_at = journal_entry.created_at journal_entry.updated_at = journal_entry.created_at
db.session.commit() db.session.commit()
@ -576,7 +576,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.client, self.client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": line_item.currency_code, "currency-1-code": line_item.currency_code,
"currency-1-debit-1-original_line_item_id": line_item.id, "currency-1-debit-1-original_line_item_id": line_item.id,
"currency-1-debit-1-account_code": line_item.account_code, "currency-1-debit-1-account_code": line_item.account_code,
@ -1112,7 +1112,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
self.assertIsNotNone(journal_entry) self.assertIsNotNone(journal_entry)
journal_entry.created_at \ journal_entry.created_at \
= journal_entry.created_at - timedelta(seconds=5) = journal_entry.created_at - dt.timedelta(seconds=5)
journal_entry.updated_at = journal_entry.created_at journal_entry.updated_at = journal_entry.created_at
db.session.commit() db.session.commit()
@ -1773,7 +1773,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
self.assertIsNotNone(journal_entry) self.assertIsNotNone(journal_entry)
journal_entry.created_at \ journal_entry.created_at \
= journal_entry.created_at - timedelta(seconds=5) = journal_entry.created_at - dt.timedelta(seconds=5)
journal_entry.updated_at = journal_entry.created_at journal_entry.updated_at = journal_entry.created_at
db.session.commit() db.session.commit()
@ -2124,9 +2124,9 @@ class JournalEntryReorderTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
journal_entry_1: JournalEntry = db.session.get(JournalEntry, id_1) journal_entry_1: JournalEntry = db.session.get(JournalEntry, id_1)
journal_entry_date_2: date = journal_entry_1.date journal_entry_date_2: dt.date = journal_entry_1.date
journal_entry_date_1: date \ journal_entry_date_1: dt.date \
= journal_entry_date_2 - timedelta(days=1) = journal_entry_date_2 - dt.timedelta(days=1)
journal_entry_1.date = journal_entry_date_1 journal_entry_1.date = journal_entry_date_1
journal_entry_1.no = 3 journal_entry_1.no = 3
journal_entry_2: JournalEntry = db.session.get(JournalEntry, id_2) journal_entry_2: JournalEntry = db.session.get(JournalEntry, id_2)
@ -2176,10 +2176,10 @@ class JournalEntryReorderTestCase(unittest.TestCase):
self.__get_add_disbursement_form()) self.__get_add_disbursement_form())
with self.app.app_context(): with self.app.app_context():
journal_entry_date: date = db.session.get(JournalEntry, id_1).date date: dt.date = db.session.get(JournalEntry, id_1).date
response = self.client.post( response = self.client.post(
f"{PREFIX}/dates/{journal_entry_date.isoformat()}", f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": "/next",
f"{id_1}-no": "4", f"{id_1}-no": "4",
@ -2207,7 +2207,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
f"{PREFIX}/dates/{journal_entry_date.isoformat()}", f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": "/next",
f"{id_2}-no": "3a", f"{id_2}-no": "3a",

View File

@ -26,9 +26,10 @@ import httpx
from flask import Flask from flask import Flask
from test_site import db from test_site import db
from testlib import Accounts, create_test_app, get_client, \ from test_site.lib import JournalEntryLineItemData, JournalEntryCurrencyData, \
match_journal_entry_detail, JournalEntryLineItemData, \ JournalEntryData, BaseTestData
JournalEntryCurrencyData, JournalEntryData, BaseTestData from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
match_journal_entry_detail
PREFIX: str = "/accounting/journal-entries" PREFIX: str = "/accounting/journal-entries"
"""The URL prefix for the journal entry management.""" """The URL prefix for the journal entry management."""
@ -51,8 +52,8 @@ class OffsetTestCase(unittest.TestCase):
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
self.data: OffsetTestData = OffsetTestData( self.data: OffsetTestData = OffsetTestData(self.app, "editor")
self.app, self.client, self.csrf_token) self.data.populate()
def test_add_receivable_offset(self) -> None: def test_add_receivable_offset(self) -> None:
"""Tests to add the receivable offset. """Tests to add the receivable offset.
@ -84,14 +85,14 @@ class OffsetTestCase(unittest.TestCase):
original_line_item=self.data.l_r_or3d)])]) original_line_item=self.data.l_r_or3d)])])
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] = "9999" form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id) = str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -106,7 +107,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token)) store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.app.app_context():
@ -115,7 +117,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id) = str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
@ -124,21 +126,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # Not the same account
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount = str(journal_entry_data.currencies[0].credit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -147,7 +149,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-3-amount"] \ form["currency-1-credit-3-amount"] \
= str(journal_entry_data.currencies[0].credit[2].amount = str(journal_entry_data.currencies[0].credit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -158,14 +160,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days = journal_entry_data.days old_days = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
@ -194,14 +196,14 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("600") journal_entry_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] = "9999" form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id) = str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -217,7 +219,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
update_uri, data=journal_entry_data.update_form(self.csrf_token)) update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.app.app_context():
@ -226,7 +229,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id) = str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
@ -235,21 +238,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -261,7 +264,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -275,14 +278,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
@ -307,21 +310,21 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4") journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4")
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -333,7 +336,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-2-amount"] \ form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount = str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -347,21 +350,21 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items # Not after the offset items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1 journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Not deleting matched original line items # Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
del form["currency-1-debit-1-id"] del form["currency-1-debit-1-id"]
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # Success
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
@ -408,14 +411,14 @@ class OffsetTestCase(unittest.TestCase):
[])]) [])])
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] = "9999" form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id) = str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -430,7 +433,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token)) store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.app.app_context():
@ -439,7 +443,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id) = str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
@ -448,21 +452,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # Not the same account
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -471,7 +475,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -482,14 +486,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.new_form(self.csrf_token) form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
@ -518,14 +522,14 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("900") journal_entry_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] = "9999" form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id) = str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -541,7 +545,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
update_uri, data=journal_entry_data.update_form(self.csrf_token)) update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.app.app_context():
@ -550,7 +555,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id) = str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
@ -559,21 +564,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -585,7 +590,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -599,14 +604,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
@ -635,21 +640,21 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9") journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9")
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -661,7 +666,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-2-amount"] \ form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount = str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -675,21 +680,21 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items # Not after the offset items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1 journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Not deleting matched original line items # Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
del form["currency-1-credit-1-id"] del form["currency-1-credit-1-id"]
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # Success
form = journal_entry_data.update_form(self.csrf_token) form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],

View File

@ -17,8 +17,8 @@
"""The test for the options. """The test for the options.
""" """
import datetime as dt
import unittest import unittest
from datetime import datetime, timedelta
import httpx import httpx
from flask import Flask from flask import Flask
@ -286,7 +286,8 @@ class OptionTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
timestamp: datetime = option.created_at - timedelta(seconds=5) timestamp: dt.datetime \
= option.created_at - dt.timedelta(seconds=5)
option.created_at = timestamp option.created_at = timestamp
option.updated_at = timestamp option.updated_at = timestamp
db.session.commit() db.session.commit()

View File

@ -17,13 +17,14 @@
"""The test for the reports. """The test for the reports.
""" """
import datetime as dt
import unittest import unittest
from datetime import date
import httpx import httpx
from flask import Flask from flask import Flask
from testlib import create_test_app, get_client, Accounts, BaseTestData from test_site.lib import BaseTestData
from testlib import create_test_app, get_client, Accounts
PREFIX: str = "/accounting" PREFIX: str = "/accounting"
"""The URL prefix for the reports.""" """The URL prefix for the reports."""
@ -55,7 +56,7 @@ class ReportTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") client, csrf_token = get_client(self.app, "nobody")
ReportTestData(self.app, self.client, self.csrf_token) ReportTestData(self.app, "editor").populate()
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -106,10 +107,26 @@ class ReportTestCase(unittest.TestCase):
response = client.get(f"{PREFIX}/unapplied?as=csv") response = client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") response = client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") response = client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403)
response = client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/search?q=Salary") response = client.get(f"{PREFIX}/search?q=Salary")
@ -130,7 +147,7 @@ class ReportTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") client, csrf_token = get_client(self.app, "viewer")
ReportTestData(self.app, self.client, self.csrf_token) ReportTestData(self.app, "editor").populate()
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -189,13 +206,29 @@ class ReportTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") response = client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") response = client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403)
response = client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/search?q=Salary") response = client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -215,7 +248,7 @@ class ReportTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
ReportTestData(self.app, self.client, self.csrf_token) ReportTestData(self.app, "editor").populate()
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX) response = self.client.get(PREFIX)
@ -274,11 +307,28 @@ class ReportTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") response = self.client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get( response = self.client.get(
f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
@ -359,11 +409,28 @@ class ReportTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") response = self.client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get( response = self.client.get(
f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
@ -379,15 +446,15 @@ class ReportTestData(BaseTestData):
"""The report test data.""" """The report test data."""
def _init_data(self) -> None: def _init_data(self) -> None:
today: date = date.today() today: dt.date = dt.date.today()
year: int = today.year - 5 year: int = today.year - 5
month: int = today.month month: int = today.month
while True: while True:
j_date: date = date(year, month, 5) date: dt.date = dt.date(year, month, 5)
if j_date > today: if date > today:
break break
self._add_simple_journal_entry( self._add_simple_journal_entry(
(j_date - today).days, "USD", (date - today).days, "USD",
"Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE) "Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE)
month = month + 1 month = month + 1
if month > 12: if month > 12:

View File

@ -71,6 +71,9 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth from . import auth
auth.init_app(app) auth.init_app(app)
from . import reset
reset.init_app(app)
class UserUtilities(accounting.UserUtilityInterface[auth.User]): class UserUtilities(accounting.UserUtilityInterface[auth.User]):
def can_view(self) -> bool: def can_view(self) -> bool:

View File

@ -17,22 +17,25 @@
"""The authentication for the Mia! Accounting demonstration website. """The authentication for the Mia! Accounting demonstration website.
""" """
import typing as t
from flask import Blueprint, render_template, Flask, redirect, url_for, \ from flask import Blueprint, render_template, Flask, redirect, url_for, \
session, request, g, Response session, request, g, Response, abort
from sqlalchemy.orm import Mapped, mapped_column
from . import db from . import db
bp: Blueprint = Blueprint("auth", __name__, url_prefix="/") bp: Blueprint = Blueprint("auth", __name__, url_prefix="/")
"""The authentication blueprint."""
class User(db.Model): class User(db.Model):
"""A user.""" """A user."""
__tablename__ = "users" __tablename__ = "users"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
autoincrement=True) """The ID."""
"""The ID""" username: Mapped[str] = mapped_column(unique=True)
username = db.Column(db.String, nullable=False, unique=True)
"""The username.""" """The username."""
def __str__(self) -> str: def __str__(self) -> str:
@ -93,6 +96,31 @@ def current_user() -> User | None:
return g.user return g.user
def admin_required(view: t.Callable) -> t.Callable:
"""The view decorator to require the user to be an administrator.
:param view: The view.
:return: The decorated view.
"""
def decorated_view(*args, **kwargs):
"""The decorated view that tests against a permission rule.
:param args: The arguments of the view.
:param kwargs: The keyword arguments of the view.
:return: The response of the view.
:raise Forbidden: When the user is denied.
"""
from accounting.utils.next_uri import append_next
if "user" not in session:
return redirect(append_next(url_for("auth.login")))
if session["user"] != "admin":
abort(403)
return view(*args, **kwargs)
return decorated_view
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:
"""Initialize the localization. """Initialize the localization.

336
tests/test_site/lib.py Normal file
View File

@ -0,0 +1,336 @@
# The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/13
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The common library for the Mia! Accounting demonstration website.
"""
from __future__ import annotations
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
from decimal import Decimal
from secrets import randbelow
import sqlalchemy as sa
from flask import Flask
from . import db
from .auth import User
class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
BANK: str = "1113-001"
RECEIVABLE: str = "1141-001"
MACHINERY: str = "1441-001"
PAYABLE: str = "2141-001"
SERVICE: str = "4611-001"
RENT_EXPENSE: str = "6252-001"
MEAL: str = "6272-001"
class JournalEntryLineItemData:
"""The journal entry line item data."""
def __init__(self, account: str, description: str | None, amount: str,
original_line_item: JournalEntryLineItemData | None = None):
"""Constructs the journal entry line item data.
:param account: The account code.
:param description: The description.
:param amount: The amount.
:param original_line_item: The original journal entry line item.
"""
self.journal_entry: JournalEntryData | None = None
self.id: int = -1
self.no: int = -1
self.original_line_item: JournalEntryLineItemData | None \
= original_line_item
self.account: str = account
self.description: str | None = description
self.amount: Decimal = Decimal(amount)
def form(self, prefix: str, debit_credit: str, index: int,
is_update: bool) -> dict[str, str]:
"""Returns the line item as form data.
:param prefix: The prefix of the form fields.
:param debit_credit: Either "debit" or "credit".
:param index: The line item index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix = f"{prefix}-{debit_credit}-{index}"
form: dict[str, str] = {f"{prefix}-account_code": self.account,
f"{prefix}-description": self.description,
f"{prefix}-amount": str(self.amount)}
if is_update and self.id != -1:
form[f"{prefix}-id"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_line_item is not None:
assert self.original_line_item.id != -1
form[f"{prefix}-original_line_item_id"] \
= str(self.original_line_item.id)
return form
class JournalEntryCurrencyData:
"""The journal entry currency data."""
def __init__(self, currency: str, debit: list[JournalEntryLineItemData],
credit: list[JournalEntryLineItemData]):
"""Constructs the journal entry currency data.
:param currency: The currency code.
:param debit: The debit line items.
:param credit: The credit line items.
"""
self.code: str = currency
self.debit: list[JournalEntryLineItemData] = debit
self.credit: list[JournalEntryLineItemData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
:param index: The currency index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix: str = f"currency-{index}"
form: dict[str, str] = {f"{prefix}-code": self.code}
for i in range(len(self.debit)):
form.update(self.debit[i].form(prefix, "debit", i + 1, is_update))
for i in range(len(self.credit)):
form.update(self.credit[i].form(prefix, "credit", i + 1,
is_update))
return form
class JournalEntryData:
"""The journal entry data."""
def __init__(self, days: int, currencies: list[JournalEntryCurrencyData]):
"""Constructs a journal entry.
:param days: The number of days before today.
:param currencies: The journal entry currency data.
"""
self.id: int = -1
self.days: int = days
self.currencies: list[JournalEntryCurrencyData] = currencies
self.note: str | None = None
for currency in self.currencies:
for line_item in currency.debit:
line_item.journal_entry = self
for line_item in currency.credit:
line_item.journal_entry = self
def new_form(self, csrf_token: str, next_uri: str) -> dict[str, str]:
"""Returns the journal entry as a creation form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:return: The journal entry as a creation form.
"""
return self.__form(csrf_token, next_uri, is_update=False)
def update_form(self, csrf_token: str, next_uri: str) -> dict[str, str]:
"""Returns the journal entry as an update form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:return: The journal entry as an update form.
"""
return self.__form(csrf_token, next_uri, is_update=True)
def __form(self, csrf_token: str, next_uri: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the journal entry as a form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form.
"""
date: dt.date = dt.date.today() - dt.timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": next_uri,
"date": date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None:
form["note"] = self.note
return form
class BaseTestData(ABC):
"""The base test data."""
def __init__(self, app: Flask, username: str):
"""Constructs the test data.
:param app: The Flask application.
:param username: The username.
"""
self._app: Flask = app
with self._app.app_context():
current_user: User | None = User.query\
.filter(User.username == username).first()
assert current_user is not None
self.__current_user_id: int = current_user.id
self.__journal_entries: list[dict[str, t.Any]] = []
self.__line_items: list[dict[str, t.Any]] = []
self._init_data()
@abstractmethod
def _init_data(self) -> None:
"""Initializes the test data.
:return: None
"""
def populate(self) -> None:
"""Populates the data into the database.
:return: None
"""
from accounting.models import JournalEntry, JournalEntryLineItem
with self._app.app_context():
db.session.execute(sa.insert(JournalEntry), self.__journal_entries)
db.session.execute(sa.insert(JournalEntryLineItem),
self.__line_items)
db.session.commit()
@staticmethod
def _couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
def _add_journal_entry(self, journal_entry_data: JournalEntryData) -> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import Account
existing_j_id: set[int] = {x["id"] for x in self.__journal_entries}
existing_l_id: set[int] = {x["id"] for x in self.__line_items}
journal_entry_data.id = self.__new_id(existing_j_id)
date: dt.date \
= dt.date.today() - dt.timedelta(days=journal_entry_data.days)
self.__journal_entries.append(
{"id": journal_entry_data.id,
"date": date,
"no": self.__next_j_no(date),
"note": journal_entry_data.note,
"created_by_id": self.__current_user_id,
"updated_by_id": self.__current_user_id})
debit_no: int = 0
credit_no: int = 0
for currency in journal_entry_data.currencies:
for line_item in currency.debit:
account: Account | None \
= Account.find_by_code(line_item.account)
assert account is not None
debit_no = debit_no + 1
line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \
= {"id": line_item.id,
"journal_entry_id": journal_entry_data.id,
"is_debit": True,
"no": debit_no,
"account_id": account.id,
"currency_code": currency.code,
"description": line_item.description,
"amount": line_item.amount}
if line_item.original_line_item is not None:
data["original_line_item_id"] \
= line_item.original_line_item.id
self.__line_items.append(data)
for line_item in currency.credit:
account: Account | None \
= Account.find_by_code(line_item.account)
assert account is not None
credit_no = credit_no + 1
line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \
= {"id": line_item.id,
"journal_entry_id": journal_entry_data.id,
"is_debit": False,
"no": credit_no,
"account_id": account.id,
"currency_code": currency.code,
"description": line_item.description,
"amount": line_item.amount}
if line_item.original_line_item is not None:
data["original_line_item_id"] \
= line_item.original_line_item.id
self.__line_items.append(data)
@staticmethod
def __new_id(existing_id: set[int]) -> int:
"""Generates and returns a new random unique ID.
:param existing_id: The existing ID.
:return: The newly-generated random unique ID.
"""
while True:
obj_id: int = 100000000 + randbelow(900000000)
if obj_id not in existing_id:
existing_id.add(obj_id)
return obj_id
def __next_j_no(self, date: dt.date) -> int:
"""Returns the next journal entry number in a day.
:param date: The journal entry date.
:return: The next journal entry number.
"""
existing: set[int] = {x["no"] for x in self.__journal_entries
if x["date"] == date}
return 1 if len(existing) == 0 else max(existing) + 1
def _add_simple_journal_entry(
self, days: int, currency: str, description: str, amount: str,
debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Adds a simple journal entry.
:param days: The number of days before today.
:param currency: The currency code.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
debit_item, credit_item = self._couple(
description, amount, debit, credit)
self._add_journal_entry(JournalEntryData(
days, [JournalEntryCurrencyData(
currency, [debit_item], [credit_item])]))
return debit_item, credit_item

370
tests/test_site/reset.py Normal file
View File

@ -0,0 +1,370 @@
# The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/12
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The data reset for the Mia! Accounting demonstration website.
"""
import datetime as dt
from flask import Flask, Blueprint, url_for, flash, redirect, session, \
render_template, current_app
from flask_babel import lazy_gettext
from . import db
from .auth import admin_required
from .lib import Accounts, JournalEntryLineItemData, JournalEntryData, \
JournalEntryCurrencyData, BaseTestData
bp: Blueprint = Blueprint("reset", __name__, url_prefix="/")
@bp.get("reset", endpoint="reset-page")
@admin_required
def reset() -> str:
"""Resets the sample data.
:return: Redirection to the accounting application.
"""
return render_template("reset.html")
@bp.post("sample", endpoint="sample")
@admin_required
def reset_sample() -> redirect:
"""Resets the sample data.
:return: Redirection to the accounting application.
"""
from accounting.utils.cast import s
__reset_database()
SampleData(current_app, "editor").populate()
flash(s(lazy_gettext(
"The sample data are emptied and reset successfully.")), "success")
return redirect(url_for("accounting-report.default"))
@bp.post("reset", endpoint="clean-up")
@admin_required
def clean_up() -> redirect:
"""Clean-up the database data.
:return: Redirection to the accounting application.
"""
from accounting.utils.cast import s
__reset_database()
flash(s(lazy_gettext("The database is emptied successfully.")), "success")
return redirect(url_for("accounting-report.default"))
def __reset_database() -> None:
"""Resets the database.
:return: None.
"""
from accounting.models import Currency, CurrencyL10n, BaseAccount, \
BaseAccountL10n, Account, AccountL10n, JournalEntry, \
JournalEntryLineItem
from accounting.base_account import init_base_accounts_command
from accounting.account import init_accounts_command
from accounting.currency import init_currencies_command
JournalEntryLineItem.query.delete()
JournalEntry.query.delete()
CurrencyL10n.query.delete()
Currency.query.delete()
AccountL10n.query.delete()
Account.query.delete()
BaseAccountL10n.query.delete()
BaseAccount.query.delete()
init_base_accounts_command()
init_accounts_command(session["user"])
init_currencies_command(session["user"])
db.session.commit()
class SampleData(BaseTestData):
"""The sample data."""
def _init_data(self) -> None:
self.__add_recurring()
self.__add_offsets()
self.__add_meals()
def __add_recurring(self) -> None:
"""Adds the recurring data.
:return: None.
"""
self.__add_usd_recurring()
self.__add_twd_recurring()
def __add_usd_recurring(self) -> None:
"""Adds the recurring data in USD.
:return: None.
"""
today: dt.date = dt.date.today()
days: int
year: int
month: int
# Recurring in USD
date: dt.date = dt.date(today.year - 5, today.month, today.day)
date = date + dt.timedelta(days=(4 - date.weekday()))
days = (today - date).days
while True:
if days < 0:
break
self.__add_journal_entry(
days, "USD", "2600",
Accounts.BANK, "Transfer", Accounts.SERVICE, "Payroll")
days = days - 1
if days < 0:
break
self.__add_journal_entry(
days, "USD", "1200",
Accounts.CASH, None, Accounts.BANK, "Withdraw")
days = days - 13
year = today.year - 5
month = today.month
while True:
month = month + 1
if month > 12:
year = year + 1
month = 1
days = (today - dt.date(year, month, 1)).days
if days < 0:
break
self.__add_journal_entry(
days, "USD", "1800",
Accounts.RENT_EXPENSE, "Rent", Accounts.BANK, "Transfer")
def __add_twd_recurring(self) -> None:
"""Adds the recurring data in TWD.
:return: None.
"""
today: dt.date = dt.date.today()
year: int = today.year - 5
month: int = today.month
while True:
days: int = (today - dt.date(year, month, 5)).days
if days < 0:
break
self.__add_journal_entry(
days, "TWD", "50000",
Accounts.BANK, "薪資轉帳", Accounts.SERVICE, "薪水")
days = days - 1
if days < 0:
break
self.__add_journal_entry(
days, "TWD", "25000",
Accounts.CASH, None, Accounts.BANK, "提款")
days = days - 4
if days < 0:
break
self.__add_journal_entry(
days, "TWD", "18000",
Accounts.RENT_EXPENSE, "房租", Accounts.BANK, "轉帳")
month = month + 1
if month > 12:
year = year + 1
month = 1
def __add_offsets(self) -> None:
"""Adds the offset data.
:return: None.
"""
days: int
year: int
month: int
description: str
line_item_or: JournalEntryLineItemData
line_item_of: JournalEntryLineItemData
# Full offset and unmatched in USD
description = "Speaking—Institute"
line_item_or = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "120")
self._add_journal_entry(JournalEntryData(
40, [JournalEntryCurrencyData(
"USD", [line_item_or], [JournalEntryLineItemData(
Accounts.SERVICE, description, "120")])]))
line_item_of = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "120",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
5, [JournalEntryCurrencyData(
"USD", [JournalEntryLineItemData(
Accounts.BANK, description, "120")],
[line_item_of])]))
self.__add_journal_entry(
30, "USD", "120",
Accounts.BANK, description, Accounts.SERVICE, description)
# Partial offset in USD
line_item_or = JournalEntryLineItemData(
Accounts.PAYABLE, "Computer", "1600")
self._add_journal_entry(JournalEntryData(
60, [JournalEntryCurrencyData(
"USD", [JournalEntryLineItemData(
Accounts.MACHINERY, "Computer", "1600")],
[line_item_or])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "Computer", "800",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
35, [JournalEntryCurrencyData(
"USD", [line_item_of], [JournalEntryLineItemData(
Accounts.BANK, "Computer", "800")])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "Computer", "400",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
10, [JournalEntryCurrencyData(
"USD", [line_item_of], [JournalEntryLineItemData(
Accounts.CASH, "Computer", "400")])]))
# Full offset and unmatched in TWD
description = "演講費—母校"
line_item_or = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "3000")
self._add_journal_entry(JournalEntryData(
45, [JournalEntryCurrencyData(
"TWD", [line_item_or], [JournalEntryLineItemData(
Accounts.SERVICE, description, "3000")])]))
line_item_of = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "3000",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
6, [JournalEntryCurrencyData(
"TWD", [JournalEntryLineItemData(
Accounts.BANK, description, "3000")],
[line_item_of])]))
self.__add_journal_entry(
25, "TWD", "3000",
Accounts.BANK, description, Accounts.SERVICE, description)
# Partial offset in TWD
line_item_or = JournalEntryLineItemData(
Accounts.PAYABLE, "手機", "30000")
self._add_journal_entry(JournalEntryData(
55, [JournalEntryCurrencyData(
"TWD", [JournalEntryLineItemData(
Accounts.MACHINERY, "手機", "30000")],
[line_item_or])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "手機", "16000",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
27, [JournalEntryCurrencyData(
"TWD", [line_item_of], [JournalEntryLineItemData(
Accounts.BANK, "手機", "16000")])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "手機", "6000",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
8, [JournalEntryCurrencyData(
"TWD", [line_item_of], [JournalEntryLineItemData(
Accounts.CASH, "手機", "6000")])]))
def __add_meals(self) -> None:
"""Adds the meal data.
:return: None.
"""
days = 60
while days >= 0:
# Meals in USD
if days % 4 == 2:
self.__add_journal_entry(
days, "USD", "2.9",
Accounts.MEAL, "Lunch—Coffee", Accounts.CASH, None)
else:
self.__add_journal_entry(
days, "USD", "3.9",
Accounts.MEAL, "Lunch—Coffee", Accounts.CASH, None)
if days % 15 == 3:
self.__add_journal_entry(
days, "USD", "5.45",
Accounts.MEAL, "Dinner—Pizza",
Accounts.PAYABLE, "Dinner—Pizza")
else:
self.__add_journal_entry(
days, "USD", "5.9",
Accounts.MEAL, "Dinner—Pasta", Accounts.CASH, None)
# Meals in TWD
if days % 5 == 3:
self.__add_journal_entry(
days, "TWD", "125",
Accounts.MEAL, "午餐—鄰家咖啡", Accounts.CASH, None)
else:
self.__add_journal_entry(
days, "TWD", "80",
Accounts.MEAL, "午餐—便當", Accounts.CASH, None)
if days % 15 == 3:
self.__add_journal_entry(
days, "TWD", "320",
Accounts.MEAL, "晚餐—牛排", Accounts.PAYABLE, "晚餐—牛排")
else:
self.__add_journal_entry(
days, "TWD", "100",
Accounts.MEAL, "晚餐—自助餐", Accounts.CASH, None)
days = days - 1
def __add_journal_entry(
self, days: int, currency: str, amount: str,
debit_account: str, debit_description: str | None,
credit_account: str, credit_description: str | None) -> None:
"""Adds a simple journal entry.
:param days: The number of days before today.
:param currency: The currency code.
:param amount: The amount.
:param debit_account: The debit account code.
:param debit_description: The debit description.
:param credit_account: The credit account code.
:param credit_description: The credit description.
:return: None.
"""
self._add_journal_entry(JournalEntryData(
days,
[JournalEntryCurrencyData(
currency,
[JournalEntryLineItemData(
debit_account, debit_description, amount)],
[JournalEntryLineItemData(
credit_account, credit_description, amount)])]))
def init_app(app: Flask) -> None:
"""Initialize the localization.
:param app: The Flask application.
:return: None.
"""
app.register_blueprint(bp)

View File

@ -72,6 +72,14 @@ First written: 2023/1/27
</button> </button>
</form> </form>
</li> </li>
{% if current_user().username == "admin" %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("reset.") %} active {% endif %}" href="{{ url_for("reset.reset-page") }}">
<i class="fa-solid fa-rotate-right"></i>
{{ _("Reset") }}
</a>
</li>
{% endif %}
</ul> </ul>
</li> </li>
{% else %} {% else %}

View File

@ -0,0 +1,48 @@
{#
The Mia! Accounting Demonstration Website
reset.html: The reset page.
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/12
#}
{% extends "base.html" %}
{% block header %}{% block title %}{{ _("Reset Database") }}{% endblock %}{% endblock %}
{% block content %}
<p>{{ _("Warning: All the current accounting data will be deleted. This cannot be undone. Please backup your database first.") }}</p>
<p>{{ _("Database reset is provided by the live demonstration. This is not part of the Mia! Accounting project.") }}</p>
<form class="mb-2" action="{{ url_for("reset.clean-up") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<button class="btn btn-primary" type="submit">{{ _("Empty the Database") }}</button>
</form>
<form class="mb-2" action="{{ url_for("reset.sample") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<button class="btn btn-primary" type="submit">{{ _("Empty and reset the Sample Data") }}</button>
</form>
{% endblock %}

View File

@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: mia-accounting-test-site 1.0.0\n" "Project-Id-Version: mia-accounting-test-site 1.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-11 22:18+0800\n" "POT-Creation-Date: 2023-04-12 17:59+0800\n"
"PO-Revision-Date: 2023-04-11 22:18+0800\n" "PO-Revision-Date: 2023-04-12 18:00+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -20,6 +20,14 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n" "Generated-By: Babel 2.12.1\n"
#: tests/test_site/reset.py:55
msgid "The sample data are emptied and reset successfully."
msgstr "範例資料已清空重設。"
#: tests/test_site/reset.py:68
msgid "The database is emptied successfully."
msgstr "資料庫已清空。"
#: tests/test_site/templates/base.html:23 #: tests/test_site/templates/base.html:23
msgid "en" msgid "en"
msgstr "zh-Hant" msgstr "zh-Hant"
@ -32,12 +40,16 @@ msgstr "首頁"
msgid "Log Out" msgid "Log Out"
msgstr "登出" msgstr "登出"
#: tests/test_site/templates/base.html:81 #: tests/test_site/templates/base.html:79
msgid "Reset"
msgstr "重設"
#: tests/test_site/templates/base.html:89
#: tests/test_site/templates/login.html:24 #: tests/test_site/templates/login.html:24
msgid "Log In" msgid "Log In"
msgstr "登入" msgstr "登入"
#: tests/test_site/templates/base.html:122 #: tests/test_site/templates/base.html:130
msgid "Error:" msgid "Error:"
msgstr "錯誤:" msgstr "錯誤:"
@ -77,3 +89,27 @@ msgstr "管理者"
msgid "Nobody" msgid "Nobody"
msgstr "沒有權限者" msgstr "沒有權限者"
#: tests/test_site/templates/reset.html:24
msgid "Reset Database"
msgstr "資料庫重設"
#: tests/test_site/templates/reset.html:28
msgid ""
"Warning: All the current accounting data will be deleted. This cannot be"
" undone. Please backup your database first."
msgstr "警告:現有資料會全部刪除,無法復原。請先備份您的資料。"
#: tests/test_site/templates/reset.html:30
msgid ""
"Database reset is provided by the live demonstration. This is not part "
"of the Mia! Accounting project."
msgstr "資料庫重設是示範站的功能,不是 Mia! Accounting 的功能。"
#: tests/test_site/templates/reset.html:37
msgid "Empty the Database"
msgstr "清空資料庫"
#: tests/test_site/templates/reset.html:45
msgid "Empty and reset the Sample Data"
msgstr "清空並重設範例資料"

View File

@ -23,10 +23,11 @@ import httpx
from flask import Flask from flask import Flask
from test_site import db from test_site import db
from testlib import create_test_app, get_client, Accounts, \ from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \
JournalEntryCurrencyData, JournalEntryData, BaseTestData BaseTestData
from testlib import NEXT_URI, create_test_app, get_client, Accounts
PREFIX: str = "/accounting/unmatched-offsets" PREFIX: str = "/accounting/match-offsets/USD"
"""The URL prefix for the unmatched offset management.""" """The URL prefix for the unmatched offset management."""
@ -54,17 +55,12 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") client, csrf_token = get_client(self.app, "nobody")
DifferentTestData(self.app, self.client, self.csrf_token) DifferentTestData(self.app, "nobody").populate()
response: httpx.Response response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token}) data={"csrf_token": csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None: def test_viewer(self) -> None:
@ -73,17 +69,12 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") client, csrf_token = get_client(self.app, "viewer")
DifferentTestData(self.app, self.client, self.csrf_token) DifferentTestData(self.app, "viewer").populate()
response: httpx.Response response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token}) data={"csrf_token": csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor(self) -> None: def test_editor(self) -> None:
@ -91,20 +82,14 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
DifferentTestData(self.app, self.client, self.csrf_token) DifferentTestData(self.app, "editor").populate()
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"], NEXT_URI)
f"{PREFIX}/{Accounts.PAYABLE}")
def test_empty_db(self) -> None: def test_empty_db(self) -> None:
"""Test the empty database. """Test the empty database.
@ -113,43 +98,42 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
""" """
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"], NEXT_URI)
f"{PREFIX}/{Accounts.PAYABLE}")
def test_different(self) -> None: def test_different(self) -> None:
"""Tests to match against different descriptions and amounts. """Tests to match against different descriptions and amounts.
:return: None. :return: None.
""" """
from accounting.models import Account, JournalEntryLineItem from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher from accounting.report.utils.offset_matcher import OffsetMatcher
data: DifferentTestData \ from accounting.template_globals import default_currency_code
= DifferentTestData(self.app, self.client, self.csrf_token) data: DifferentTestData = DifferentTestData(self.app, "editor")
data.populate()
account: Account | None account: Account | None
line_item: JournalEntryLineItem | None line_item: JournalEntryLineItem | None
matcher: OffsetMatcher matcher: OffsetMatcher
list_uri: str
match_uri: str match_uri: str
response: httpx.Response response: httpx.Response
with self.app.app_context():
currency: Currency | None \
= db.session.get(Currency, default_currency_code())
assert currency is not None
# The receivables # The receivables
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id, {data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id, data.l_r_or4d.id}) data.l_r_or3d.id, data.l_r_or4d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_r_of1c.id, data.l_r_of2c.id, {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id, data.l_r_of3c.id, data.l_r_of4c.id,
data.l_r_of5c.id}) data.l_r_of5c.id})
@ -163,24 +147,24 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNotNone(line_item) self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id) self.assertIsNone(line_item.original_line_item_id)
list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri, response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id, {data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id}) data.l_r_or3d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_r_of1c.id, data.l_r_of2c.id, {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id}) data.l_r_of3c.id, data.l_r_of4c.id})
self.assertEqual(matcher.matches, 0) self.assertEqual(len(matcher.matched_pairs), 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id, for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id}: data.l_r_of3c.id, data.l_r_of4c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id) line_item = db.session.get(JournalEntryLineItem, line_item_id)
@ -195,11 +179,11 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id, {data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id, data.l_p_or4c.id}) data.l_p_or3c.id, data.l_p_or4c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_p_of1d.id, data.l_p_of2d.id, {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id, data.l_p_of3d.id, data.l_p_of4d.id,
data.l_p_of5d.id}) data.l_p_of5d.id})
@ -213,24 +197,24 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNotNone(line_item) self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id) self.assertIsNone(line_item.original_line_item_id)
list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
match_uri = f"{PREFIX}/{Accounts.PAYABLE}" match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri, response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id, {data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id}) data.l_p_or3c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_p_of1d.id, data.l_p_of2d.id, {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id}) data.l_p_of3d.id, data.l_p_of4d.id})
self.assertEqual(matcher.matches, 0) self.assertEqual(len(matcher.matched_pairs), 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id, for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id}: data.l_p_of3d.id, data.l_p_of4d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id) line_item = db.session.get(JournalEntryLineItem, line_item_id)
@ -246,27 +230,32 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, JournalEntryLineItem from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher from accounting.report.utils.offset_matcher import OffsetMatcher
data: SameTestData \ from accounting.template_globals import default_currency_code
= SameTestData(self.app, self.client, self.csrf_token) data: SameTestData = SameTestData(self.app, "editor")
data.populate()
account: Account | None account: Account | None
line_item: JournalEntryLineItem | None line_item: JournalEntryLineItem | None
matcher: OffsetMatcher matcher: OffsetMatcher
list_uri: str
match_uri: str match_uri: str
response: httpx.Response response: httpx.Response
with self.app.app_context():
currency: Currency | None \
= db.session.get(Currency, default_currency_code())
assert currency is not None
# The receivables # The receivables
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or3d.id, {data.l_r_or1d.id, data.l_r_or3d.id,
data.l_r_or4d.id, data.l_r_or5d.id, data.l_r_or4d.id, data.l_r_or5d.id,
data.l_r_or6d.id}) data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_r_of1c.id, data.l_r_of2c.id, {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of4c.id, data.l_r_of5c.id, data.l_r_of4c.id, data.l_r_of5c.id,
data.l_r_of6c.id}) data.l_r_of6c.id})
@ -286,22 +275,22 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNotNone(line_item.original_line_item_id) self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id) self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri, response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or5d.id, data.l_r_or6d.id}) {data.l_r_or5d.id, data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_r_of1c.id, data.l_r_of5c.id}) {data.l_r_of1c.id, data.l_r_of5c.id})
self.assertEqual(matcher.matches, 0) self.assertEqual(len(matcher.matched_pairs), 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of5c.id}: for line_item_id in {data.l_r_of1c.id, data.l_r_of5c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id) line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item) self.assertIsNotNone(line_item)
@ -327,12 +316,12 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or3c.id, {data.l_p_or1c.id, data.l_p_or3c.id,
data.l_p_or4c.id, data.l_p_or5c.id, data.l_p_or4c.id, data.l_p_or5c.id,
data.l_p_or6c.id}) data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_p_of1d.id, data.l_p_of2d.id, {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of4d.id, data.l_p_of5d.id, data.l_p_of4d.id, data.l_p_of5d.id,
data.l_p_of6d.id}) data.l_p_of6d.id})
@ -352,22 +341,22 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNotNone(line_item.original_line_item_id) self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id) self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
match_uri = f"{PREFIX}/{Accounts.PAYABLE}" match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri, response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(account) matcher = OffsetMatcher(currency, account)
self.assertEqual({x.id for x in matcher.unapplied}, self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or5c.id, data.l_p_or6c.id}) {data.l_p_or5c.id, data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets}, self.assertEqual({x.id for x in matcher.unmatched},
{data.l_p_of1d.id, data.l_p_of5d.id}) {data.l_p_of1d.id, data.l_p_of5d.id})
self.assertEqual(matcher.matches, 0) self.assertEqual(len(matcher.matched_pairs), 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of5d.id}: for line_item_id in {data.l_p_of1d.id, data.l_p_of5d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id) line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item) self.assertIsNotNone(line_item)
@ -483,14 +472,12 @@ class DifferentTestData(BaseTestData):
5, [JournalEntryCurrencyData( 5, [JournalEntryCurrencyData(
"USD", [self.l_p_of5d], [self.l_p_of5c])]) "USD", [self.l_p_of5d], [self.l_p_of5c])])
self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, False)
self._add_journal_entry(self.j_r_of1) self._add_journal_entry(self.j_r_of1)
self._add_journal_entry(self.j_r_of2) self._add_journal_entry(self.j_r_of2)
self._add_journal_entry(self.j_r_of3) self._add_journal_entry(self.j_r_of3)
self._add_journal_entry(self.j_p_of1) self._add_journal_entry(self.j_p_of1)
self._add_journal_entry(self.j_p_of2) self._add_journal_entry(self.j_p_of2)
self._add_journal_entry(self.j_p_of3) self._add_journal_entry(self.j_p_of3)
self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, True)
class SameTestData(BaseTestData): class SameTestData(BaseTestData):
@ -525,8 +512,6 @@ class SameTestData(BaseTestData):
self.l_p_or6d, self.l_p_or6c = self._add_simple_journal_entry( self.l_p_or6d, self.l_p_or6c = self._add_simple_journal_entry(
10, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE) 10, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, False)
# Receivable offset items # Receivable offset items
self.l_r_of1d, self.l_r_of1c = self._add_simple_journal_entry( self.l_r_of1d, self.l_r_of1c = self._add_simple_journal_entry(
65, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE) 65, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
@ -563,6 +548,5 @@ class SameTestData(BaseTestData):
self.l_p_of6d, self.l_p_of6c = self._add_simple_journal_entry( self.l_p_of6d, self.l_p_of6c = self._add_simple_journal_entry(
15, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH) 15, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, True)
self._add_journal_entry(j_r_of3) self._add_journal_entry(j_r_of3)
self._add_journal_entry(j_p_of3) self._add_journal_entry(j_p_of3)

View File

@ -21,15 +21,11 @@ from __future__ import annotations
import re import re
import typing as t import typing as t
from abc import ABC, abstractmethod
from datetime import date, timedelta
from _decimal import Decimal
import httpx import httpx
from flask import Flask, render_template_string from flask import Flask, render_template_string
from test_site import create_app, db from test_site import create_app
TEST_SERVER: str = "https://testserver" TEST_SERVER: str = "https://testserver"
"""The test server URI.""" """The test server URI."""
@ -44,6 +40,7 @@ class Accounts:
BANK: str = "1113-001" BANK: str = "1113-001"
NOTES_RECEIVABLE: str = "1131-001" NOTES_RECEIVABLE: str = "1131-001"
RECEIVABLE: str = "1141-001" RECEIVABLE: str = "1141-001"
MACHINERY: str = "1441-001"
PREPAID: str = "1258-001" PREPAID: str = "1258-001"
NOTES_PAYABLE: str = "2131-001" NOTES_PAYABLE: str = "2131-001"
PAYABLE: str = "2141-001" PAYABLE: str = "2141-001"
@ -158,236 +155,3 @@ def match_journal_entry_detail(location: str) -> int:
r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location) r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
assert m is not None assert m is not None
return int(m.group(1)) return int(m.group(1))
class JournalEntryLineItemData:
"""The journal entry line item data."""
def __init__(self, account: str, description: str, amount: str,
original_line_item: JournalEntryLineItemData | None = None):
"""Constructs the journal entry line item data.
:param account: The account code.
:param description: The description.
:param amount: The amount.
:param original_line_item: The original journal entry line item.
"""
self.journal_entry: JournalEntryData | None = None
self.id: int = -1
self.no: int = -1
self.original_line_item: JournalEntryLineItemData | None \
= original_line_item
self.account: str = account
self.description: str = description
self.amount: Decimal = Decimal(amount)
def form(self, prefix: str, debit_credit: str, index: int,
is_update: bool) -> dict[str, str]:
"""Returns the line item as form data.
:param prefix: The prefix of the form fields.
:param debit_credit: Either "debit" or "credit".
:param index: The line item index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix = f"{prefix}-{debit_credit}-{index}"
form: dict[str, str] = {f"{prefix}-account_code": self.account,
f"{prefix}-description": self.description,
f"{prefix}-amount": str(self.amount)}
if is_update and self.id != -1:
form[f"{prefix}-id"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_line_item is not None:
assert self.original_line_item.id != -1
form[f"{prefix}-original_line_item_id"] \
= str(self.original_line_item.id)
return form
class JournalEntryCurrencyData:
"""The journal entry currency data."""
def __init__(self, currency: str, debit: list[JournalEntryLineItemData],
credit: list[JournalEntryLineItemData]):
"""Constructs the journal entry currency data.
:param currency: The currency code.
:param debit: The debit line items.
:param credit: The credit line items.
"""
self.code: str = currency
self.debit: list[JournalEntryLineItemData] = debit
self.credit: list[JournalEntryLineItemData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
:param index: The currency index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix: str = f"currency-{index}"
form: dict[str, str] = {f"{prefix}-code": self.code}
for i in range(len(self.debit)):
form.update(self.debit[i].form(prefix, "debit", i + 1, is_update))
for i in range(len(self.credit)):
form.update(self.credit[i].form(prefix, "credit", i + 1,
is_update))
return form
class JournalEntryData:
"""The journal entry data."""
def __init__(self, days: int, currencies: list[JournalEntryCurrencyData]):
"""Constructs a journal entry.
:param days: The number of days before today.
:param currencies: The journal entry currency data.
"""
self.id: int = -1
self.days: int = days
self.currencies: list[JournalEntryCurrencyData] = currencies
self.note: str | None = None
for currency in self.currencies:
for line_item in currency.debit:
line_item.journal_entry = self
for line_item in currency.credit:
line_item.journal_entry = self
def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the journal entry as a creation form.
:param csrf_token: The CSRF token.
:return: The journal entry as a creation form.
"""
return self.__form(csrf_token, is_update=False)
def update_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the journal entry as an update form.
:param csrf_token: The CSRF token.
:return: The journal entry as an update form.
"""
return self.__form(csrf_token, is_update=True)
def __form(self, csrf_token: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the journal entry as a form.
:param csrf_token: The CSRF token.
:param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form.
"""
journal_entry_date: date = date.today() - timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": journal_entry_date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None:
form["note"] = self.note
return form
class BaseTestData(ABC):
"""The base test data."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
self._init_data()
@abstractmethod
def _init_data(self) -> None:
"""Initializes the test data.
:return: None
"""
@staticmethod
def _couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
def _add_journal_entry(self, journal_entry_data: JournalEntryData) -> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import JournalEntry
store_uri: str = "/accounting/journal-entries/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
assert response.status_code == 302
journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"])
journal_entry_data.id = journal_entry_id
with self.app.app_context():
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, journal_entry_id)
assert journal_entry is not None
for i in range(len(journal_entry.currencies)):
for j in range(len(journal_entry.currencies[i].debit)):
journal_entry_data.currencies[i].debit[j].id \
= journal_entry.currencies[i].debit[j].id
for j in range(len(journal_entry.currencies[i].credit)):
journal_entry_data.currencies[i].credit[j].id \
= journal_entry.currencies[i].credit[j].id
def _add_simple_journal_entry(
self, days: int, currency: str, description: str, amount: str,
debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Adds a simple journal entry.
:param days: The number of days before today.
:param currency: The currency code.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
debit_item, credit_item = self._couple(
description, amount, debit, credit)
self._add_journal_entry(JournalEntryData(
days, [JournalEntryCurrencyData(
currency, [debit_item], [credit_item])]))
return debit_item, credit_item
def _set_need_offset(self, account_codes: set[str],
is_need_offset: bool) -> None:
"""Sets whether the line items in some accounts need offset.
:param account_codes: The account codes.
:param is_need_offset: True if the line items in the accounts need
offset, or False otherwise.
:return:
"""
from accounting.models import Account
with self.app.app_context():
for code in account_codes:
account: Account | None = Account.find_by_code(code)
assert account is not None
account.is_need_offset = is_need_offset
db.session.commit()

View File

@ -17,8 +17,8 @@
"""The common test libraries for the journal entry test cases. """The common test libraries for the journal entry test cases.
""" """
import datetime as dt
import re import re
from datetime import date
from decimal import Decimal from decimal import Decimal
from secrets import randbelow from secrets import randbelow
@ -41,7 +41,7 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
""" """
return {"csrf_token": csrf_token, return {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-no": "16", "currency-0-debit-0-no": "16",
"currency-0-debit-0-account_code": Accounts.CASH, "currency-0-debit-0-account_code": Accounts.CASH,