Compare commits
	
		
			68 Commits
		
	
	
		
			v1.0.0
			...
			1224d6f83e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1224d6f83e | |||
| 3a8618f7c3 | |||
| 5d87205659 | |||
| 04de4f5c5e | |||
| f8ea863b80 | |||
| 5ae0d03b32 | |||
| a9a3ad5871 | |||
| 5edc95afce | |||
| 943ace6fc7 | |||
| a63bc977e9 | |||
| dabe6ddbca | |||
| f47e9b3150 | |||
| bb5383febe | |||
| 87f9063ceb | |||
| 51f0185bcf | |||
| 7ca08d6cc8 | |||
| c8e9e562be | |||
| ba43bd7e90 | |||
| 4e550413ba | |||
| 59a3cbb472 | |||
| d1b64d069e | |||
| d823d3254f | |||
| 5e9a2fb0c3 | |||
| 3f2e659ba5 | |||
| 9f7bb6b9de | |||
| 6857164702 | |||
| 6bac76be64 | |||
| 370d2668e5 | |||
| 5e3e695e62 | |||
| 510d369e9c | |||
| b65cae9252 | |||
| 285c12406b | |||
| df240472a4 | |||
| 1218b224fc | |||
| 79689ac0e5 | |||
| 1660e66766 | |||
| 12d00c9c7d | |||
| 428018e4a9 | |||
| a8f318b0bb | |||
| a3507494e5 | |||
| 3aa6c8d6f6 | |||
| 052b62cdd4 | |||
| 3728a4037d | |||
| 6eee17d44f | |||
| e5cc2b5a2f | |||
| ac3b5523b1 | |||
| 5af6fd9619 | |||
| 71a20cba29 | |||
| 4a4cf1ea40 | |||
| e9824808ec | |||
| c984d2d596 | |||
| 720e77c814 | |||
| 0f0412827d | |||
| 3a0e978f76 | |||
| 8c10d42d7b | |||
| 04ec51afbe | |||
| fe7a8842ce | |||
| 66daa5c42c | |||
| 27fb44937d | |||
| 7026ed3a65 | |||
| fdd3e93778 | |||
| def7559457 | |||
| 7905820d68 | |||
| 7ae332c975 | |||
| 86c5b91697 | |||
| 9168840e64 | |||
| 21b9cfa8b8 | |||
| b0b3b3acb1 | 
							
								
								
									
										107
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										107
									
								
								README.rst
									
									
									
									
									
								
							@@ -17,47 +17,6 @@ accounting reports:
 | 
			
		||||
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
 | 
			
		||||
receivables.
 | 
			
		||||
 | 
			
		||||
You may try the `Mia! Accounting live demonstration`_.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
History
 | 
			
		||||
=======
 | 
			
		||||
 | 
			
		||||
I created my own private accounting application in Perl_/mod_perl_ in
 | 
			
		||||
2007, as part of my personal website.  The first revision was made
 | 
			
		||||
using Perl/Mojolicious_ in 2019, with the aim of making it
 | 
			
		||||
mobile-friendly using Bootstrap_, and with modern back-end and
 | 
			
		||||
front-end technologies such as jQuery.
 | 
			
		||||
 | 
			
		||||
The second revision was done in Python_/Django_ in 2020, as I was
 | 
			
		||||
looking to change my career from PHP_/Laravel_ to Python, but lacked
 | 
			
		||||
experience with large Python projects.  I wanted to add something new
 | 
			
		||||
to my portfolio and decided to work on the somewhat outdated
 | 
			
		||||
Mojolicious project.
 | 
			
		||||
 | 
			
		||||
Despite having no prior experience with Django, I spent two months
 | 
			
		||||
working late nights to create the `Mia! Account Django application`_.
 | 
			
		||||
It took me another 1.5 months to make it an independent module, which
 | 
			
		||||
I later released as an open source project.
 | 
			
		||||
 | 
			
		||||
The application worked nicely for my household bookkeeping for two
 | 
			
		||||
years.  However, new demands arose over time, especially with tracking
 | 
			
		||||
payables and receivables, which became difficult with credit card
 | 
			
		||||
payments.  This was critical `during the pandemic`_ as more payments
 | 
			
		||||
were made online with credit cards.
 | 
			
		||||
 | 
			
		||||
The biggest issue I encountered was with Django's MVT framework.  Due
 | 
			
		||||
to my lack of experience with Django during development, I ended up
 | 
			
		||||
with mixed function-based view controllers and class-based views.  It
 | 
			
		||||
became very difficult to track whether problems originated from my
 | 
			
		||||
overridden methods or not-overridden methods, or from the Django base
 | 
			
		||||
views themselves.  I did not fully understand how everything worked.
 | 
			
		||||
 | 
			
		||||
Therefore, I decided to turn to microframeworks like Flask.   After
 | 
			
		||||
working with modularized Flask and FastAPI_ applications for two
 | 
			
		||||
years, I returned to the project and wrote its third revision using
 | 
			
		||||
Flask in 2023.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Installation
 | 
			
		||||
============
 | 
			
		||||
@@ -72,6 +31,18 @@ You may also download the from the `PyPI project page`_ or the
 | 
			
		||||
`release page`_ on the `Git repository`_.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Test Site and Live Demonstration
 | 
			
		||||
================================
 | 
			
		||||
 | 
			
		||||
You may find a working example in the `test site`_ in the
 | 
			
		||||
`source distribution`_.  It is the simplest website that works with
 | 
			
		||||
*Mia! Accounting*.  It is used in the automatic tests.  It is the same
 | 
			
		||||
code run for `live demonstration`_.
 | 
			
		||||
 | 
			
		||||
If you do not have a running Flask application or do not know how to
 | 
			
		||||
start one, you may start with the test site.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Prerequisites
 | 
			
		||||
=============
 | 
			
		||||
 | 
			
		||||
@@ -91,7 +62,7 @@ Configuration
 | 
			
		||||
=============
 | 
			
		||||
 | 
			
		||||
You need to pass the Flask *app* and an implementation of
 | 
			
		||||
``UserUtilityInterface`` to the ``init_app`` function.
 | 
			
		||||
`UserUtilityInterface`_ to the `init_app`_ function.
 | 
			
		||||
``UserUtilityInterface`` contains everything *Mia! Accounting* needs.
 | 
			
		||||
 | 
			
		||||
The following is an example configuration for *Mia! Accounting*.
 | 
			
		||||
@@ -109,7 +80,7 @@ The following is an example configuration for *Mia! Accounting*.
 | 
			
		||||
 | 
			
		||||
        import accounting
 | 
			
		||||
 | 
			
		||||
        class UserUtilities(accounting.UserUtilityInterface[User]):
 | 
			
		||||
        class UserUtils(accounting.UserUtilityInterface[User]):
 | 
			
		||||
 | 
			
		||||
            def can_view(self) -> bool:
 | 
			
		||||
                return True
 | 
			
		||||
@@ -161,8 +132,8 @@ database tables that *Mia! Accounting* uses.
 | 
			
		||||
* ``accounting-init-accounts``
 | 
			
		||||
* ``accounting-init-currencies``
 | 
			
		||||
 | 
			
		||||
You need to run ``accounting-init-base`` first, and then the other
 | 
			
		||||
two commands.
 | 
			
		||||
After database tables are created, run
 | 
			
		||||
``accounting-init-base`` first, and then the other two commands.
 | 
			
		||||
 | 
			
		||||
::
 | 
			
		||||
 | 
			
		||||
@@ -196,18 +167,6 @@ base template:
 | 
			
		||||
Check your Flask application and see how it works.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Test Site and Live Demonstration
 | 
			
		||||
================================
 | 
			
		||||
 | 
			
		||||
You may find a working example in the `test site`_ in the
 | 
			
		||||
`source distribution`_.  It is the simplest website that works with
 | 
			
		||||
*Mia! Accounting*.  It is used in the automatic tests.  It is the same
 | 
			
		||||
code run for `live demonstration`_.
 | 
			
		||||
 | 
			
		||||
If you do not have a running Flask application, you may start with the
 | 
			
		||||
test site.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Documentation
 | 
			
		||||
=============
 | 
			
		||||
 | 
			
		||||
@@ -242,29 +201,19 @@ Authors
 | 
			
		||||
 | 
			
		||||
.. _Flask: https://flask.palletsprojects.com
 | 
			
		||||
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
 | 
			
		||||
.. _Mia! Accounting live demonstration: https://accounting.imacat.idv.tw/
 | 
			
		||||
.. _Perl: https://www.perl.org
 | 
			
		||||
.. _mod_perl: https://perl.apache.org
 | 
			
		||||
.. _Mojolicious: https://mojolicious.org
 | 
			
		||||
.. _Bootstrap: https://getbootstrap.com
 | 
			
		||||
.. _jQuery: https://jquery.com
 | 
			
		||||
.. _Python: https://www.python.org
 | 
			
		||||
.. _Django: https://www.djangoproject.com
 | 
			
		||||
.. _PHP: https://www.php.net
 | 
			
		||||
.. _Laravel: https://laravel.com
 | 
			
		||||
.. _Mia! Account Django application: https://github.com/imacat/mia-accounting-django
 | 
			
		||||
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
 | 
			
		||||
.. _FastAPI: https://fastapi.tiangolo.com
 | 
			
		||||
.. _FontAwesome: https://fontawesome.com
 | 
			
		||||
.. _Decimal.js: https://mikemcl.github.io/decimal.js
 | 
			
		||||
.. _Tempus-Dominus: https://getdatepicker.com
 | 
			
		||||
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
 | 
			
		||||
.. _PyPI project page: https://pypi.org/project/mia-accounting
 | 
			
		||||
.. _release page: https://github.com/imacat/mia-accounting/releases
 | 
			
		||||
.. _Git repository: https://github.com/imacat/mia-accounting
 | 
			
		||||
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
 | 
			
		||||
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
 | 
			
		||||
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
 | 
			
		||||
.. _source distribution: https://pypi.org/project/mia-accounting/#files
 | 
			
		||||
.. _live demonstration: https://accounting.imacat.idv.tw
 | 
			
		||||
.. _PyPI project page: https://pypi.org/project/mia-accounting
 | 
			
		||||
.. _release page: https://github.com/imacat/mia-accounting/releases
 | 
			
		||||
.. _Git repository: https://github.com/imacat/mia-accounting
 | 
			
		||||
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
 | 
			
		||||
.. _Bootstrap: https://getbootstrap.com
 | 
			
		||||
.. _FontAwesome: https://fontawesome.com
 | 
			
		||||
.. _Decimal.js: https://mikemcl.github.io/decimal.js
 | 
			
		||||
.. _Tempus-Dominus: https://getdatepicker.com
 | 
			
		||||
.. _UserUtilityInterface: https://mia-accounting.readthedocs.io/en/latest/accounting.utils.html#accounting.utils.user.UserUtilityInterface
 | 
			
		||||
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
 | 
			
		||||
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
 | 
			
		||||
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
 | 
			
		||||
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
 | 
			
		||||
 
 | 
			
		||||
@@ -20,14 +20,6 @@ accounting.journal\_entry.utils.description\_editor module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.journal\_entry.utils.offset\_alias module
 | 
			
		||||
----------------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.journal_entry.utils.offset_alias
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.journal\_entry.utils.operators module
 | 
			
		||||
------------------------------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -60,6 +60,22 @@ accounting.report.reports.trial\_balance module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.report.reports.unapplied module
 | 
			
		||||
------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.report.reports.unapplied
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.report.reports.unapplied\_accounts module
 | 
			
		||||
----------------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.report.reports.unapplied_accounts
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
Module contents
 | 
			
		||||
---------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,14 @@ accounting.report.utils.report\_type module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.report.utils.unapplied module
 | 
			
		||||
----------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.report.utils.unapplied
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.report.utils.urls module
 | 
			
		||||
-----------------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ Subpackages
 | 
			
		||||
   accounting.journal_entry
 | 
			
		||||
   accounting.option
 | 
			
		||||
   accounting.report
 | 
			
		||||
   accounting.unmatched_offset
 | 
			
		||||
   accounting.utils
 | 
			
		||||
 | 
			
		||||
Submodules
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								docs/source/accounting.unmatched_offset.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								docs/source/accounting.unmatched_offset.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
accounting.unmatched\_offset package
 | 
			
		||||
====================================
 | 
			
		||||
 | 
			
		||||
Submodules
 | 
			
		||||
----------
 | 
			
		||||
 | 
			
		||||
accounting.unmatched\_offset.queries module
 | 
			
		||||
-------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.unmatched_offset.queries
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.unmatched\_offset.views module
 | 
			
		||||
-----------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.unmatched_offset.views
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
Module contents
 | 
			
		||||
---------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.unmatched_offset
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
@@ -44,6 +44,22 @@ accounting.utils.next\_uri module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.offset\_alias module
 | 
			
		||||
-------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.offset_alias
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.offset\_matcher module
 | 
			
		||||
---------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.offset_matcher
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.options module
 | 
			
		||||
-------------------------------
 | 
			
		||||
 | 
			
		||||
@@ -92,6 +108,14 @@ accounting.utils.strip\_text module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.unapplied module
 | 
			
		||||
---------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.unapplied
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.user module
 | 
			
		||||
----------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
 | 
			
		||||
project = 'Mia! Accounting'
 | 
			
		||||
copyright = '2023, imacat'
 | 
			
		||||
author = 'imacat'
 | 
			
		||||
release = '1.0.0'
 | 
			
		||||
release = '1.1.0'
 | 
			
		||||
 | 
			
		||||
# -- General configuration ---------------------------------------------------
 | 
			
		||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,7 @@ The following is an example configuration for *Mia! Accounting*.
 | 
			
		||||
 | 
			
		||||
        import accounting
 | 
			
		||||
 | 
			
		||||
        class UserUtilities(accounting.UserUtilityInterface[User]):
 | 
			
		||||
        class UserUtils(accounting.UserUtilityInterface[User]):
 | 
			
		||||
 | 
			
		||||
            def can_view(self) -> bool:
 | 
			
		||||
                return True
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										57
									
								
								docs/source/history.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								docs/source/history.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
			
		||||
History
 | 
			
		||||
=======
 | 
			
		||||
 | 
			
		||||
I created my own private accounting application in Perl_/mod_perl_ in
 | 
			
		||||
2007, as part of my personal website.  The first revision was made
 | 
			
		||||
using Perl/Mojolicious_ in 2019, with the aim of making it
 | 
			
		||||
mobile-friendly using Bootstrap_, and with modern back-end and
 | 
			
		||||
front-end technologies such as jQuery.
 | 
			
		||||
 | 
			
		||||
The second revision was done in Python_/Django_ in 2020, as I was
 | 
			
		||||
looking to change my career from PHP_/Laravel_ to Python, but lacked
 | 
			
		||||
experience with large Python projects.  I needed something in my
 | 
			
		||||
portfolio and decided to work on the somewhat outdated Mojolicious
 | 
			
		||||
project.
 | 
			
		||||
 | 
			
		||||
Despite having no prior experience with Django, I spent two months
 | 
			
		||||
working late nights to create the `Mia! Accounting Django`_
 | 
			
		||||
application.  It took me another 1.5 months to make it an independent
 | 
			
		||||
module, which I later released as an open source project on PyPI.
 | 
			
		||||
 | 
			
		||||
The application worked nicely for my household bookkeeping for two
 | 
			
		||||
years.  However, new demands arose over time, especially with tracking
 | 
			
		||||
payables and receivables.  This was critical `during the pandemic`_ as
 | 
			
		||||
more payments were made online with credit cards.
 | 
			
		||||
 | 
			
		||||
The biggest issue I encountered was with
 | 
			
		||||
`Django's MTV architectural pattern`_.  Django takes over the control
 | 
			
		||||
flow.  I had to override several parts of the `class-based views`_ for
 | 
			
		||||
different but yet simple control flow logic.  In the end, it became
 | 
			
		||||
very difficult to track whether things went wrong because I overrode
 | 
			
		||||
something or because it just wouldn't work with the basic assumption
 | 
			
		||||
of the class-based views.  By the time I realized it, it was too late
 | 
			
		||||
for me to drop Django's MTV and rewrite everything from class-based
 | 
			
		||||
views to function-based views.
 | 
			
		||||
 | 
			
		||||
Therefore, I decided to turn to microframeworks_ like Flask_.   After
 | 
			
		||||
working with modularized Flask and FastAPI_ applications for two
 | 
			
		||||
years, I returned to the project and wrote its third revision using
 | 
			
		||||
Flask in 2023.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.. _Perl: https://www.perl.org
 | 
			
		||||
.. _mod_perl: https://perl.apache.org
 | 
			
		||||
.. _Mojolicious: https://mojolicious.org
 | 
			
		||||
.. _Bootstrap: https://getbootstrap.com
 | 
			
		||||
.. _jQuery: https://jquery.com
 | 
			
		||||
.. _Python: https://www.python.org
 | 
			
		||||
.. _Django: https://www.djangoproject.com
 | 
			
		||||
.. _PHP: https://www.php.net
 | 
			
		||||
.. _Laravel: https://laravel.com
 | 
			
		||||
.. _Mia! Accounting Django: https://github.com/imacat/mia-accounting-django
 | 
			
		||||
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
 | 
			
		||||
.. _FastAPI: https://fastapi.tiangolo.com
 | 
			
		||||
.. _Django's MTV architectural pattern: https://docs.djangoproject.com/en/dev/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names
 | 
			
		||||
.. _class-based views: https://docs.djangoproject.com/en/4.2/topics/class-based-views/
 | 
			
		||||
.. _microframeworks: https://en.wikipedia.org/wiki/Microframework
 | 
			
		||||
.. _Flask: https://flask.palletsprojects.com
 | 
			
		||||
@@ -13,6 +13,7 @@ Welcome to Mia! Accounting's documentation!
 | 
			
		||||
   intro
 | 
			
		||||
   accounting
 | 
			
		||||
   examples
 | 
			
		||||
   history
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -12,47 +12,6 @@ accounting reports:
 | 
			
		||||
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
 | 
			
		||||
receivables.
 | 
			
		||||
 | 
			
		||||
You may try the `Mia! Accounting live demonstration`_.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
History
 | 
			
		||||
-------
 | 
			
		||||
 | 
			
		||||
I created my own private accounting application in Perl_/mod_perl_ in
 | 
			
		||||
2007, as part of my personal website.  The first revision was made
 | 
			
		||||
using Perl/Mojolicious_ in 2019, with the aim of making it
 | 
			
		||||
mobile-friendly using Bootstrap_, and with modern back-end and
 | 
			
		||||
front-end technologies such as jQuery.
 | 
			
		||||
 | 
			
		||||
The second revision was done in Python_/Django_ in 2020, as I was
 | 
			
		||||
looking to change my career from PHP_/Laravel_ to Python, but lacked
 | 
			
		||||
experience with large Python projects.  I wanted to add something new
 | 
			
		||||
to my portfolio and decided to work on the somewhat outdated
 | 
			
		||||
Mojolicious project.
 | 
			
		||||
 | 
			
		||||
Despite having no prior experience with Django, I spent two months
 | 
			
		||||
working late nights to create the `Mia! Account Django application`_.
 | 
			
		||||
It took me another 1.5 months to make it an independent module, which
 | 
			
		||||
I later released as an open source project.
 | 
			
		||||
 | 
			
		||||
The application worked nicely for my household bookkeeping for two
 | 
			
		||||
years.  However, new demands arose over time, especially with tracking
 | 
			
		||||
payables and receivables, which became difficult with credit card
 | 
			
		||||
payments.  This was critical `during the pandemic`_ as more payments
 | 
			
		||||
were made online with credit cards.
 | 
			
		||||
 | 
			
		||||
The biggest issue I encountered was with Django's MVT framework.  Due
 | 
			
		||||
to my lack of experience with Django during development, I ended up
 | 
			
		||||
with mixed function-based view controllers and class-based views.  It
 | 
			
		||||
became very difficult to track whether problems originated from my
 | 
			
		||||
overridden methods or not-overridden methods, or from the Django base
 | 
			
		||||
views themselves.  I did not fully understand how everything worked.
 | 
			
		||||
 | 
			
		||||
Therefore, I decided to turn to microframeworks like Flask.   After
 | 
			
		||||
working with modularized Flask and FastAPI_ applications for two
 | 
			
		||||
years, I returned to the project and wrote its third revision using
 | 
			
		||||
Flask in 2023.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Installation
 | 
			
		||||
------------
 | 
			
		||||
@@ -67,6 +26,18 @@ You may also download the from the `PyPI project page`_ or the
 | 
			
		||||
`release page`_ on the `Git repository`_.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Test Site and Live Demonstration
 | 
			
		||||
--------------------------------
 | 
			
		||||
 | 
			
		||||
You may find a working example in the `test site`_ in the
 | 
			
		||||
`source distribution`_.  It is the simplest website that works with
 | 
			
		||||
*Mia! Accounting*.  It is used in the automatic tests.  It is the same
 | 
			
		||||
code run for `live demonstration`_.
 | 
			
		||||
 | 
			
		||||
If you do not have a running Flask application or do not know how to
 | 
			
		||||
start one, you may start with the test site.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Prerequisites
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
@@ -97,7 +68,7 @@ Database Initialization
 | 
			
		||||
-----------------------
 | 
			
		||||
 | 
			
		||||
After the configuration, you need to run
 | 
			
		||||
:py:meth:`flask_sqlalchemy.SQLAlchemy.create_all` to create the
 | 
			
		||||
`flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
 | 
			
		||||
database tables that *Mia! Accounting* uses.
 | 
			
		||||
 | 
			
		||||
*Mia! Accounting* adds three console commands:
 | 
			
		||||
@@ -106,8 +77,8 @@ database tables that *Mia! Accounting* uses.
 | 
			
		||||
* ``accounting-init-accounts``
 | 
			
		||||
* ``accounting-init-currencies``
 | 
			
		||||
 | 
			
		||||
You need to run ``accounting-init-base`` first, and then the other
 | 
			
		||||
two commands.
 | 
			
		||||
After database tables are created, run
 | 
			
		||||
``accounting-init-base`` first, and then the other two commands.
 | 
			
		||||
 | 
			
		||||
::
 | 
			
		||||
 | 
			
		||||
@@ -141,18 +112,6 @@ base template:
 | 
			
		||||
Check your Flask application and see how it works.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Test Site and Live Demonstration
 | 
			
		||||
--------------------------------
 | 
			
		||||
 | 
			
		||||
You may find a working example in the `test site`_ in the
 | 
			
		||||
`source distribution`_.  It is the simplest website that works with
 | 
			
		||||
*Mia! Accounting*.  It is used in the automatic tests.  It is the same
 | 
			
		||||
code run for `live demonstration`_.
 | 
			
		||||
 | 
			
		||||
If you do not have a running Flask application, you may start with the
 | 
			
		||||
test site.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Documentation
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
@@ -161,28 +120,17 @@ Refer to the `documentation on Read the Docs`_.
 | 
			
		||||
 | 
			
		||||
.. _Flask: https://flask.palletsprojects.com
 | 
			
		||||
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
 | 
			
		||||
.. _Mia! Accounting live demonstration: https://accounting.imacat.idv.tw/
 | 
			
		||||
.. _Perl: https://www.perl.org
 | 
			
		||||
.. _mod_perl: https://perl.apache.org
 | 
			
		||||
.. _Mojolicious: https://mojolicious.org
 | 
			
		||||
.. _Bootstrap: https://getbootstrap.com
 | 
			
		||||
.. _jQuery: https://jquery.com
 | 
			
		||||
.. _Python: https://www.python.org
 | 
			
		||||
.. _Django: https://www.djangoproject.com
 | 
			
		||||
.. _PHP: https://www.php.net
 | 
			
		||||
.. _Laravel: https://laravel.com
 | 
			
		||||
.. _Mia! Account Django application: https://github.com/imacat/mia-accounting-django
 | 
			
		||||
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
 | 
			
		||||
.. _FastAPI: https://fastapi.tiangolo.com
 | 
			
		||||
.. _FontAwesome: https://fontawesome.com
 | 
			
		||||
.. _Decimal.js: https://mikemcl.github.io/decimal.js
 | 
			
		||||
.. _Tempus-Dominus: https://getdatepicker.com
 | 
			
		||||
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
 | 
			
		||||
.. _PyPI project page: https://pypi.org/project/mia-accounting
 | 
			
		||||
.. _release page: https://github.com/imacat/mia-accounting/releases
 | 
			
		||||
.. _Git repository: https://github.com/imacat/mia-accounting
 | 
			
		||||
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
 | 
			
		||||
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
 | 
			
		||||
.. _source distribution: https://pypi.org/project/mia-accounting/#files
 | 
			
		||||
.. _live demonstration: https://accounting.imacat.idv.tw
 | 
			
		||||
.. _PyPI project page: https://pypi.org/project/mia-accounting
 | 
			
		||||
.. _release page: https://github.com/imacat/mia-accounting/releases
 | 
			
		||||
.. _Git repository: https://github.com/imacat/mia-accounting
 | 
			
		||||
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
 | 
			
		||||
.. _Bootstrap: https://getbootstrap.com
 | 
			
		||||
.. _FontAwesome: https://fontawesome.com
 | 
			
		||||
.. _Decimal.js: https://mikemcl.github.io/decimal.js
 | 
			
		||||
.. _Tempus-Dominus: https://getdatepicker.com
 | 
			
		||||
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
 | 
			
		||||
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
 | 
			
		||||
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
 | 
			
		||||
[project]
 | 
			
		||||
name = "mia-accounting"
 | 
			
		||||
version = "1.0.0"
 | 
			
		||||
version = "1.1.0"
 | 
			
		||||
description = "A Flask accounting module."
 | 
			
		||||
readme = "README.rst"
 | 
			
		||||
requires-python = ">=3.11"
 | 
			
		||||
 
 | 
			
		||||
@@ -88,4 +88,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
 | 
			
		||||
    from . import option
 | 
			
		||||
    option.init_app(bp)
 | 
			
		||||
 | 
			
		||||
    from . import unmatched_offset
 | 
			
		||||
    unmatched_offset.init_app(bp)
 | 
			
		||||
 | 
			
		||||
    app.register_blueprint(bp, url_prefix=url_prefix)
 | 
			
		||||
 
 | 
			
		||||
@@ -108,15 +108,15 @@ def __is_need_offset(base_code: str) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    # Assets
 | 
			
		||||
    if base_code[0] == "1":
 | 
			
		||||
        if base_code[:3] in {"113", "114", "118", "184"}:
 | 
			
		||||
        if base_code[:3] in {"113", "114", "118", "184", "186"}:
 | 
			
		||||
            return True
 | 
			
		||||
        if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
 | 
			
		||||
                         "1581", "1611", "1851", ""}:
 | 
			
		||||
        if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
 | 
			
		||||
                         "1521", "1581", "1611", "1851"}:
 | 
			
		||||
            return True
 | 
			
		||||
        return False
 | 
			
		||||
    # Liabilities
 | 
			
		||||
    if base_code[0] == "2":
 | 
			
		||||
        if base_code in {"2111", "2114", "2284", "2293"}:
 | 
			
		||||
        if base_code in {"2111", "2114", "2284", "2293", "2861"}:
 | 
			
		||||
            return False
 | 
			
		||||
        return True
 | 
			
		||||
    # Only assets and liabilities need offset
 | 
			
		||||
 
 | 
			
		||||
@@ -53,7 +53,7 @@ def list_accounts() -> str:
 | 
			
		||||
                           list=pagination.list, pagination=pagination)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/create", endpoint="create")
 | 
			
		||||
@bp.get("create", endpoint="create")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_add_account_form() -> str:
 | 
			
		||||
    """Shows the form to add an account.
 | 
			
		||||
@@ -70,7 +70,7 @@ def show_add_account_form() -> str:
 | 
			
		||||
                           form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/store", endpoint="store")
 | 
			
		||||
@bp.post("store", endpoint="store")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def add_account() -> redirect:
 | 
			
		||||
    """Adds an account.
 | 
			
		||||
@@ -91,7 +91,7 @@ def add_account() -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(account)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<account:account>", endpoint="detail")
 | 
			
		||||
@bp.get("<account:account>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_account_detail(account: Account) -> str:
 | 
			
		||||
    """Shows the account detail.
 | 
			
		||||
@@ -102,7 +102,7 @@ def show_account_detail(account: Account) -> str:
 | 
			
		||||
    return render_template("accounting/account/detail.html", obj=account)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<account:account>/edit", endpoint="edit")
 | 
			
		||||
@bp.get("<account:account>/edit", endpoint="edit")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_account_edit_form(account: Account) -> str:
 | 
			
		||||
    """Shows the form to edit an account.
 | 
			
		||||
@@ -121,7 +121,7 @@ def show_account_edit_form(account: Account) -> str:
 | 
			
		||||
                           account=account, form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<account:account>/update", endpoint="update")
 | 
			
		||||
@bp.post("<account:account>/update", endpoint="update")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def update_account(account: Account) -> redirect:
 | 
			
		||||
    """Updates an account.
 | 
			
		||||
@@ -148,7 +148,7 @@ def update_account(account: Account) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(account)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<account:account>/delete", endpoint="delete")
 | 
			
		||||
@bp.post("<account:account>/delete", endpoint="delete")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def delete_account(account: Account) -> redirect:
 | 
			
		||||
    """Deletes an account.
 | 
			
		||||
@@ -167,7 +167,7 @@ def delete_account(account: Account) -> redirect:
 | 
			
		||||
    return redirect(or_next(__get_list_uri()))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
 | 
			
		||||
@bp.get("bases/<baseAccount:base>", endpoint="order")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_account_order(base: BaseAccount) -> str:
 | 
			
		||||
    """Shows the order of the accounts under a same base account.
 | 
			
		||||
@@ -178,7 +178,7 @@ def show_account_order(base: BaseAccount) -> str:
 | 
			
		||||
    return render_template("accounting/account/order.html", base=base)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
 | 
			
		||||
@bp.post("bases/<baseAccount:base>", endpoint="sort")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def sort_accounts(base: BaseAccount) -> redirect:
 | 
			
		||||
    """Reorders the accounts under a base account.
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ from flask import Blueprint, render_template
 | 
			
		||||
from accounting.models import BaseAccount
 | 
			
		||||
from accounting.utils.pagination import Pagination
 | 
			
		||||
from accounting.utils.permission import has_permission, can_view
 | 
			
		||||
from .queries import get_base_account_query
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("base-account", __name__)
 | 
			
		||||
"""The view blueprint for the base account management."""
 | 
			
		||||
@@ -34,14 +35,13 @@ def list_accounts() -> str:
 | 
			
		||||
 | 
			
		||||
    :return: The account list.
 | 
			
		||||
    """
 | 
			
		||||
    from .queries import get_base_account_query
 | 
			
		||||
    accounts: list[BaseAccount] = get_base_account_query()
 | 
			
		||||
    pagination: Pagination = Pagination[BaseAccount](accounts)
 | 
			
		||||
    return render_template("accounting/base-account/list.html",
 | 
			
		||||
                           list=pagination.list, pagination=pagination)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<baseAccount:account>", endpoint="detail")
 | 
			
		||||
@bp.get("<baseAccount:account>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_account_detail(account: BaseAccount) -> str:
 | 
			
		||||
    """Shows the account detail.
 | 
			
		||||
 
 | 
			
		||||
@@ -34,6 +34,7 @@ from accounting.utils.pagination import Pagination
 | 
			
		||||
from accounting.utils.permission import has_permission, can_view, can_edit
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
from .forms import CurrencyForm
 | 
			
		||||
from .queries import get_currency_query
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("currency", __name__)
 | 
			
		||||
"""The view blueprint for the currency management."""
 | 
			
		||||
@@ -48,14 +49,13 @@ def list_currencies() -> str:
 | 
			
		||||
 | 
			
		||||
    :return: The currency list.
 | 
			
		||||
    """
 | 
			
		||||
    from .queries import get_currency_query
 | 
			
		||||
    currencies: list[Currency] = get_currency_query()
 | 
			
		||||
    pagination: Pagination = Pagination[Currency](currencies)
 | 
			
		||||
    return render_template("accounting/currency/list.html",
 | 
			
		||||
                           list=pagination.list, pagination=pagination)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/create", endpoint="create")
 | 
			
		||||
@bp.get("create", endpoint="create")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_add_currency_form() -> str:
 | 
			
		||||
    """Shows the form to add a currency.
 | 
			
		||||
@@ -72,7 +72,7 @@ def show_add_currency_form() -> str:
 | 
			
		||||
                           form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/store", endpoint="store")
 | 
			
		||||
@bp.post("store", endpoint="store")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def add_currency() -> redirect:
 | 
			
		||||
    """Adds a currency.
 | 
			
		||||
@@ -93,7 +93,7 @@ def add_currency() -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<currency:currency>", endpoint="detail")
 | 
			
		||||
@bp.get("<currency:currency>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_currency_detail(currency: Currency) -> str:
 | 
			
		||||
    """Shows the currency detail.
 | 
			
		||||
@@ -104,7 +104,7 @@ def show_currency_detail(currency: Currency) -> str:
 | 
			
		||||
    return render_template("accounting/currency/detail.html", obj=currency)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<currency:currency>/edit", endpoint="edit")
 | 
			
		||||
@bp.get("<currency:currency>/edit", endpoint="edit")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_currency_edit_form(currency: Currency) -> str:
 | 
			
		||||
    """Shows the form to edit a currency.
 | 
			
		||||
@@ -123,7 +123,7 @@ def show_currency_edit_form(currency: Currency) -> str:
 | 
			
		||||
                           currency=currency, form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<currency:currency>/update", endpoint="update")
 | 
			
		||||
@bp.post("<currency:currency>/update", endpoint="update")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def update_currency(currency: Currency) -> redirect:
 | 
			
		||||
    """Updates a currency.
 | 
			
		||||
@@ -151,7 +151,7 @@ def update_currency(currency: Currency) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<currency:currency>/delete", endpoint="delete")
 | 
			
		||||
@bp.post("<currency:currency>/delete", endpoint="delete")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def delete_currency(currency: Currency) -> redirect:
 | 
			
		||||
    """Deletes a currency.
 | 
			
		||||
@@ -169,7 +169,7 @@ def delete_currency(currency: Currency) -> redirect:
 | 
			
		||||
    return redirect(or_next(url_for("accounting.currency.list")))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_bp.get("/exists-code", endpoint="exists")
 | 
			
		||||
@api_bp.get("exists-code", endpoint="exists")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def exists_code() -> dict[str, bool]:
 | 
			
		||||
    """Validates whether a currency code exists.
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,7 @@ from flask import abort
 | 
			
		||||
from sqlalchemy.orm import selectinload
 | 
			
		||||
from werkzeug.routing import BaseConverter
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import JournalEntry, JournalEntryLineItem
 | 
			
		||||
from accounting.utils.journal_entry_types import JournalEntryType
 | 
			
		||||
 | 
			
		||||
@@ -37,13 +38,7 @@ class JournalEntryConverter(BaseConverter):
 | 
			
		||||
        :param value: The journal entry ID.
 | 
			
		||||
        :return: The corresponding journal entry.
 | 
			
		||||
        """
 | 
			
		||||
        journal_entry: JournalEntry | None = JournalEntry.query\
 | 
			
		||||
            .join(JournalEntryLineItem)\
 | 
			
		||||
            .filter(JournalEntry.id == value)\
 | 
			
		||||
            .options(selectinload(JournalEntry.line_items)
 | 
			
		||||
                     .selectinload(JournalEntryLineItem.offsets)
 | 
			
		||||
                     .selectinload(JournalEntryLineItem.journal_entry))\
 | 
			
		||||
            .first()
 | 
			
		||||
        journal_entry: JournalEntry | None = db.session.get(JournalEntry, value)
 | 
			
		||||
        if journal_entry is None:
 | 
			
		||||
            abort(404)
 | 
			
		||||
        return journal_entry
 | 
			
		||||
 
 | 
			
		||||
@@ -28,14 +28,13 @@ from wtforms.validators import DataRequired
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.forms import CurrencyExists
 | 
			
		||||
from accounting.journal_entry.utils.offset_alias import offset_alias
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import JournalEntryLineItem
 | 
			
		||||
from accounting.utils.cast import be
 | 
			
		||||
from accounting.utils.offset_alias import offset_alias
 | 
			
		||||
from accounting.utils.strip_text import strip_text
 | 
			
		||||
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
 | 
			
		||||
    lazy_gettext("Please select the currency."))
 | 
			
		||||
"""The validator to check if the currency code is empty."""
 | 
			
		||||
 
 | 
			
		||||
@@ -31,7 +31,7 @@ from accounting import db
 | 
			
		||||
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
 | 
			
		||||
    IsCreditAccount
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
 | 
			
		||||
from accounting.template_filters import format_amount
 | 
			
		||||
from accounting.utils.cast import be
 | 
			
		||||
from accounting.utils.random_id import new_id
 | 
			
		||||
@@ -127,10 +127,8 @@ class KeepAccountWhenHavingOffset:
 | 
			
		||||
        assert isinstance(form, LineItemForm)
 | 
			
		||||
        if field.data is None or form.id.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        line_item: JournalEntryLineItem | None = db.session\
 | 
			
		||||
            .query(JournalEntryLineItem)\
 | 
			
		||||
            .filter(JournalEntryLineItem.id == form.id.data)\
 | 
			
		||||
            .options(selectinload(JournalEntryLineItem.offsets)).first()
 | 
			
		||||
        line_item: JournalEntryLineItem | None \
 | 
			
		||||
            = db.session.get(JournalEntryLineItem, form.id.data)
 | 
			
		||||
        if line_item is None or len(line_item.offsets) == 0:
 | 
			
		||||
            return
 | 
			
		||||
        if field.data != line_item.account_code:
 | 
			
		||||
@@ -344,14 +342,13 @@ class LineItemForm(FlaskForm):
 | 
			
		||||
            def get_offsets() -> list[JournalEntryLineItem]:
 | 
			
		||||
                if not self.is_need_offset or self.id.data is None:
 | 
			
		||||
                    return []
 | 
			
		||||
                return JournalEntryLineItem.query\
 | 
			
		||||
                return JournalEntryLineItem.query.join(JournalEntry)\
 | 
			
		||||
                    .filter(JournalEntryLineItem.original_line_item_id
 | 
			
		||||
                            == self.id.data)\
 | 
			
		||||
                    .order_by(JournalEntry.date, JournalEntry.no,
 | 
			
		||||
                              JournalEntryLineItem.no)\
 | 
			
		||||
                    .options(selectinload(JournalEntryLineItem.journal_entry),
 | 
			
		||||
                             selectinload(JournalEntryLineItem.account),
 | 
			
		||||
                             selectinload(JournalEntryLineItem.offsets)
 | 
			
		||||
                             .selectinload(
 | 
			
		||||
                                 JournalEntryLineItem.journal_entry)).all()
 | 
			
		||||
                             selectinload(JournalEntryLineItem.account)).all()
 | 
			
		||||
            setattr(self, "__offsets", get_offsets())
 | 
			
		||||
        return getattr(self, "__offsets")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,7 @@ from sqlalchemy.orm import selectinload
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
 | 
			
		||||
from accounting.utils.cast import be
 | 
			
		||||
from .offset_alias import offset_alias
 | 
			
		||||
from accounting.utils.offset_alias import offset_alias
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_selectable_original_line_items(
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ bp.add_app_template_filter(format_amount_input,
 | 
			
		||||
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
 | 
			
		||||
@bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
 | 
			
		||||
    """Shows the form to add a journal entry.
 | 
			
		||||
@@ -71,7 +71,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
 | 
			
		||||
    return journal_entry_op.render_create_template(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
 | 
			
		||||
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
 | 
			
		||||
    """Adds a journal entry.
 | 
			
		||||
@@ -98,7 +98,7 @@ def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
 | 
			
		||||
@bp.get("<journalEntry:journal_entry>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    """Shows the journal entry detail.
 | 
			
		||||
@@ -111,7 +111,7 @@ def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    return journal_entry_op.render_detail_template(journal_entry)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
 | 
			
		||||
@bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    """Shows the form to edit a journal entry.
 | 
			
		||||
@@ -133,7 +133,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    return journal_entry_op.render_edit_template(journal_entry, form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
 | 
			
		||||
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    """Updates a journal entry.
 | 
			
		||||
@@ -166,7 +166,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
 | 
			
		||||
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    """Deletes a journal entry.
 | 
			
		||||
@@ -186,7 +186,7 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    return redirect(or_next(__get_default_page_uri()))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
 | 
			
		||||
@bp.get("dates/<date:journal_entry_date>", endpoint="order")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_journal_entry_order(journal_entry_date: date) -> str:
 | 
			
		||||
    """Shows the order of the journal entries in a same date.
 | 
			
		||||
@@ -201,7 +201,7 @@ def show_journal_entry_order(journal_entry_date: date) -> str:
 | 
			
		||||
                           date=journal_entry_date, list=journal_entries)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
 | 
			
		||||
@bp.post("dates/<date:journal_entry_date>", endpoint="sort")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def sort_journal_entries(journal_entry_date: date) -> redirect:
 | 
			
		||||
    """Reorders the journal entries in a date.
 | 
			
		||||
 
 | 
			
		||||
@@ -214,6 +214,25 @@ class Account(db.Model):
 | 
			
		||||
        """
 | 
			
		||||
        return not self.is_real
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def count(self) -> int:
 | 
			
		||||
        """Returns the number of items in the account.
 | 
			
		||||
 | 
			
		||||
        :return: The number of items in the account.
 | 
			
		||||
        """
 | 
			
		||||
        if not hasattr(self, "__count"):
 | 
			
		||||
            setattr(self, "__count", 0)
 | 
			
		||||
        return getattr(self, "__count")
 | 
			
		||||
 | 
			
		||||
    @count.setter
 | 
			
		||||
    def count(self, count: int) -> None:
 | 
			
		||||
        """Sets the number of items in the account.
 | 
			
		||||
 | 
			
		||||
        :param count: The number of items in the account.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__count", count)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def query_values(self) -> list[str]:
 | 
			
		||||
        """Returns the values to be queried.
 | 
			
		||||
@@ -660,12 +679,8 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
                                      nullable=True)
 | 
			
		||||
    """The ID of the original line item."""
 | 
			
		||||
    original_line_item = db.relationship("JournalEntryLineItem",
 | 
			
		||||
                                         back_populates="offsets",
 | 
			
		||||
                                         remote_side=id, passive_deletes=True)
 | 
			
		||||
    """The original line item."""
 | 
			
		||||
    offsets = db.relationship("JournalEntryLineItem",
 | 
			
		||||
                              back_populates="original_line_item")
 | 
			
		||||
    """The offset items."""
 | 
			
		||||
    currency_code = db.Column(db.String,
 | 
			
		||||
                              db.ForeignKey(Currency.code, onupdate="CASCADE"),
 | 
			
		||||
                              nullable=False)
 | 
			
		||||
@@ -707,14 +722,6 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        """
 | 
			
		||||
        return self.account.code
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit(self) -> Decimal | None:
 | 
			
		||||
        """Returns the debit amount.
 | 
			
		||||
 | 
			
		||||
        :return: The debit amount, or None if this is not a debit line item.
 | 
			
		||||
        """
 | 
			
		||||
        return self.amount if self.is_debit else None
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_need_offset(self) -> bool:
 | 
			
		||||
        """Returns whether the line item needs offset.
 | 
			
		||||
@@ -729,6 +736,14 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
            return False
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit(self) -> Decimal | None:
 | 
			
		||||
        """Returns the debit amount.
 | 
			
		||||
 | 
			
		||||
        :return: The debit amount, or None if this is not a debit line item.
 | 
			
		||||
        """
 | 
			
		||||
        return self.amount if self.is_debit else None
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit(self) -> Decimal | None:
 | 
			
		||||
        """Returns the credit amount.
 | 
			
		||||
@@ -758,6 +773,40 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__net_balance", net_balance)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def offsets(self) -> list[t.Self]:
 | 
			
		||||
        """Returns the offset items.
 | 
			
		||||
 | 
			
		||||
        :return: The offset items.
 | 
			
		||||
        """
 | 
			
		||||
        if not hasattr(self, "__offsets"):
 | 
			
		||||
            cls: t.Type[t.Self] = self.__class__
 | 
			
		||||
            offsets: list[t.Self] = cls.query.join(JournalEntry)\
 | 
			
		||||
                .filter(JournalEntryLineItem.original_line_item_id == self.id)\
 | 
			
		||||
                .order_by(JournalEntry.date, JournalEntry.no,
 | 
			
		||||
                          cls.is_debit, cls.no).all()
 | 
			
		||||
            setattr(self, "__offsets", offsets)
 | 
			
		||||
        return getattr(self, "__offsets")
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def match(self) -> t.Self | None:
 | 
			
		||||
        """Returns the match of the line item.
 | 
			
		||||
 | 
			
		||||
        :return: The match of the line item.
 | 
			
		||||
        """
 | 
			
		||||
        if not hasattr(self, "__match"):
 | 
			
		||||
            setattr(self, "__match", None)
 | 
			
		||||
        return getattr(self, "__match")
 | 
			
		||||
 | 
			
		||||
    @match.setter
 | 
			
		||||
    def match(self, match: t.Self) -> None:
 | 
			
		||||
        """Sets the match of the line item.
 | 
			
		||||
 | 
			
		||||
        :param match: The matcho of the line item.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__match", match)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def query_values(self) -> list[str]:
 | 
			
		||||
        """Returns the values to be queried.
 | 
			
		||||
 
 | 
			
		||||
@@ -27,9 +27,11 @@ def init_app(app: Flask, url_prefix: str) -> None:
 | 
			
		||||
    :param url_prefix: The URL prefix of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    from .converters import PeriodConverter, IncomeExpensesAccountConverter
 | 
			
		||||
    from .converters import PeriodConverter, CurrentAccountConverter, \
 | 
			
		||||
        NeedOffsetAccountConverter
 | 
			
		||||
    app.url_map.converters["period"] = PeriodConverter
 | 
			
		||||
    app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
 | 
			
		||||
    app.url_map.converters["currentAccount"] = CurrentAccountConverter
 | 
			
		||||
    app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
 | 
			
		||||
 | 
			
		||||
    from .views import bp as report_bp
 | 
			
		||||
    app.register_blueprint(report_bp, url_prefix=url_prefix)
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,8 @@ from .period import Period, get_period
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PeriodConverter(BaseConverter):
 | 
			
		||||
    """The supplier converter to convert the period specification from and to
 | 
			
		||||
    the corresponding period in the routes."""
 | 
			
		||||
    """The converter to convert the period specification from and to the
 | 
			
		||||
    corresponding period in the routes."""
 | 
			
		||||
 | 
			
		||||
    def to_python(self, value: str) -> Period:
 | 
			
		||||
        """Converts a period specification to a period.
 | 
			
		||||
@@ -51,9 +51,9 @@ class PeriodConverter(BaseConverter):
 | 
			
		||||
        return value.spec
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeExpensesAccountConverter(BaseConverter):
 | 
			
		||||
    """The supplier converter to convert the income and expenses log pseudo
 | 
			
		||||
    account code from and to the corresponding pseudo account in the routes."""
 | 
			
		||||
class CurrentAccountConverter(BaseConverter):
 | 
			
		||||
    """The converter to convert the current account code from and to the
 | 
			
		||||
    corresponding account in the routes."""
 | 
			
		||||
 | 
			
		||||
    def to_python(self, value: str) -> CurrentAccount:
 | 
			
		||||
        """Converts an account code to an account.
 | 
			
		||||
@@ -77,3 +77,29 @@ class IncomeExpensesAccountConverter(BaseConverter):
 | 
			
		||||
        :return: Its code.
 | 
			
		||||
        """
 | 
			
		||||
        return value.code
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NeedOffsetAccountConverter(BaseConverter):
 | 
			
		||||
    """The converter to convert the unapplied original line item account code
 | 
			
		||||
    from and to the corresponding account in the routes."""
 | 
			
		||||
 | 
			
		||||
    def to_python(self, value: str) -> Account:
 | 
			
		||||
        """Converts an account code to an account.
 | 
			
		||||
 | 
			
		||||
        :param value: The account code.
 | 
			
		||||
        :return: The corresponding account.
 | 
			
		||||
        """
 | 
			
		||||
        account: Account | None = Account.find_by_code(value)
 | 
			
		||||
        if account is None:
 | 
			
		||||
            abort(404)
 | 
			
		||||
        if not account.is_need_offset:
 | 
			
		||||
            abort(404)
 | 
			
		||||
        return account
 | 
			
		||||
 | 
			
		||||
    def to_url(self, value: Account) -> str:
 | 
			
		||||
        """Converts an account to account code.
 | 
			
		||||
 | 
			
		||||
        :param value: The account.
 | 
			
		||||
        :return: Its code.
 | 
			
		||||
        """
 | 
			
		||||
        return value.code
 | 
			
		||||
 
 | 
			
		||||
@@ -77,6 +77,8 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_date: The journal entry date.
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param debit: The debit amount.
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
@@ -116,6 +118,7 @@ class PageParams(BasePageParams):
 | 
			
		||||
        """Constructs the HTML page parameters.
 | 
			
		||||
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        :param pagination: The pagination.
 | 
			
		||||
        :param line_items: The line items.
 | 
			
		||||
        """
 | 
			
		||||
        self.period: Period = period
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										185
									
								
								src/accounting/report/reports/unapplied.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/accounting/report/reports/unapplied.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,185 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The unapplied original line items.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
			
		||||
from accounting.report.utils.base_report import BaseReport
 | 
			
		||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
 | 
			
		||||
from accounting.report.utils.option_link import OptionLink
 | 
			
		||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
			
		||||
from accounting.report.utils.report_type import ReportType
 | 
			
		||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied
 | 
			
		||||
from accounting.report.utils.urls import unapplied_url
 | 
			
		||||
from accounting.utils.offset_matcher import OffsetMatcher
 | 
			
		||||
from accounting.utils.pagination import Pagination
 | 
			
		||||
from accounting.utils.permission import can_edit
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, journal_entry_date: str | date, currency: str,
 | 
			
		||||
                 description: str | None, amount: str | Decimal,
 | 
			
		||||
                 net_balance: str | Decimal):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_date: The journal entry date.
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param net_balance: The net balance.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: str | date = journal_entry_date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.currency: str = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.description: str | None = description
 | 
			
		||||
        """The description."""
 | 
			
		||||
        self.amount: str | Decimal = amount
 | 
			
		||||
        """The amount."""
 | 
			
		||||
        self.net_balance: str | Decimal = net_balance
 | 
			
		||||
        """The net balance."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | date | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
        """
 | 
			
		||||
        return [self.date, self.currency, self.description, self.amount,
 | 
			
		||||
                self.net_balance]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account,
 | 
			
		||||
                 is_mark_matches: bool,
 | 
			
		||||
                 pagination: Pagination[JournalEntryLineItem],
 | 
			
		||||
                 line_items: list[JournalEntryLineItem]):
 | 
			
		||||
        """Constructs the HTML page parameters.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param is_mark_matches: Whether to mark the matched offsets.
 | 
			
		||||
        :param pagination: The pagination.
 | 
			
		||||
        :param line_items: The line items.
 | 
			
		||||
        """
 | 
			
		||||
        self.account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.pagination: Pagination[JournalEntryLineItem] = pagination
 | 
			
		||||
        """The pagination."""
 | 
			
		||||
        self.line_items: list[JournalEntryLineItem] = line_items
 | 
			
		||||
        """The line items."""
 | 
			
		||||
        self.is_mark_matches: bool = is_mark_matches
 | 
			
		||||
        """Whether to mark the matched offsets."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def has_data(self) -> bool:
 | 
			
		||||
        """Returns whether there is any data on the page.
 | 
			
		||||
 | 
			
		||||
        :return: True if there is any data, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        return len(self.line_items) > 0
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
        return ReportChooser(ReportType.UNAPPLIED,
 | 
			
		||||
                             account=self.account)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the account options.
 | 
			
		||||
 | 
			
		||||
        :return: The account options.
 | 
			
		||||
        """
 | 
			
		||||
        options: list[OptionLink] = [OptionLink(gettext("Accounts"),
 | 
			
		||||
                                                unapplied_url(None),
 | 
			
		||||
                                                False)]
 | 
			
		||||
        options.extend([OptionLink(str(x),
 | 
			
		||||
                                   unapplied_url(x),
 | 
			
		||||
                                   x.id == self.account.id)
 | 
			
		||||
                        for x in get_accounts_with_unapplied()])
 | 
			
		||||
        return options
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
 | 
			
		||||
    """Composes and returns the CSV rows from the line items.
 | 
			
		||||
 | 
			
		||||
    :param line_items: The line items.
 | 
			
		||||
    :return: The CSV rows.
 | 
			
		||||
    """
 | 
			
		||||
    rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
 | 
			
		||||
                                 gettext("Description"), gettext("Amount"),
 | 
			
		||||
                                 gettext("Net Balance"))]
 | 
			
		||||
    rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
 | 
			
		||||
                        x.description, x.amount, x.net_balance)
 | 
			
		||||
                 for x in line_items])
 | 
			
		||||
    return rows
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UnappliedOriginalLineItems(BaseReport):
 | 
			
		||||
    """The unapplied original line items."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account):
 | 
			
		||||
        """Constructs the unapplied original line items.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        """
 | 
			
		||||
        self.__account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
 | 
			
		||||
        self.__line_items: list[JournalEntryLineItem] \
 | 
			
		||||
            = offset_matcher.unapplied
 | 
			
		||||
        """The line items."""
 | 
			
		||||
        self.__is_mark_matches: bool \
 | 
			
		||||
            = can_edit() and len(offset_matcher.unmatched_offsets) > 0
 | 
			
		||||
        """Whether to mark the matched offsets."""
 | 
			
		||||
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = f"unapplied-{self.__account.code}.csv"
 | 
			
		||||
        return csv_download(filename, get_csv_rows(self.__line_items))
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        pagination: Pagination[JournalEntryLineItem] \
 | 
			
		||||
            = Pagination[JournalEntryLineItem](self.__line_items,
 | 
			
		||||
                                               is_reversed=True)
 | 
			
		||||
        params: PageParams = PageParams(account=self.__account,
 | 
			
		||||
                                        is_mark_matches=self.__is_mark_matches,
 | 
			
		||||
                                        pagination=pagination,
 | 
			
		||||
                                        line_items=pagination.list)
 | 
			
		||||
        return render_template("accounting/report/unapplied.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										137
									
								
								src/accounting/report/reports/unapplied_accounts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								src/accounting/report/reports/unapplied_accounts.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,137 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The accounts with unapplied original line items.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
			
		||||
from accounting.report.utils.base_report import BaseReport
 | 
			
		||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
 | 
			
		||||
from accounting.report.utils.option_link import OptionLink
 | 
			
		||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
			
		||||
from accounting.report.utils.report_type import ReportType
 | 
			
		||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied
 | 
			
		||||
from accounting.report.utils.urls import unapplied_url
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: str, count: int | str):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param count: The number of unapplied original line items.
 | 
			
		||||
        """
 | 
			
		||||
        self.account: str = account
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.count: int | str = count
 | 
			
		||||
        """The number of unapplied original line items."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | date | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
        """
 | 
			
		||||
        return [self.account, self.count]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, accounts: list[Account]):
 | 
			
		||||
        """Constructs the HTML page parameters.
 | 
			
		||||
 | 
			
		||||
        :param accounts: The accounts.
 | 
			
		||||
        """
 | 
			
		||||
        self.accounts: list[Account] = accounts
 | 
			
		||||
        """The accounts."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def has_data(self) -> bool:
 | 
			
		||||
        """Returns whether there is any data on the page.
 | 
			
		||||
 | 
			
		||||
        :return: True if there is any data, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        return len(self.accounts) > 0
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
        return ReportChooser(ReportType.UNAPPLIED)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the account options.
 | 
			
		||||
 | 
			
		||||
        :return: The account options.
 | 
			
		||||
        """
 | 
			
		||||
        options: list[OptionLink] = [OptionLink(gettext("Accounts"),
 | 
			
		||||
                                                unapplied_url(None),
 | 
			
		||||
                                                True)]
 | 
			
		||||
        options.extend([OptionLink(str(x),
 | 
			
		||||
                                   unapplied_url(x),
 | 
			
		||||
                                   False)
 | 
			
		||||
                        for x in self.accounts])
 | 
			
		||||
        return options
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
 | 
			
		||||
    """Composes and returns the CSV rows from the line items.
 | 
			
		||||
 | 
			
		||||
    :param accounts: The accounts.
 | 
			
		||||
    :return: The CSV rows.
 | 
			
		||||
    """
 | 
			
		||||
    rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
 | 
			
		||||
    rows.extend([CSVRow(str(x).title(), x.count)
 | 
			
		||||
                 for x in accounts])
 | 
			
		||||
    return rows
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountsWithUnappliedOriginalLineItems(BaseReport):
 | 
			
		||||
    """The accounts with unapplied original line items."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        """Constructs the outstanding balances."""
 | 
			
		||||
        self.__accounts: list[Account] = get_accounts_with_unapplied()
 | 
			
		||||
        """The accounts."""
 | 
			
		||||
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = f"unapplied-accounts.csv"
 | 
			
		||||
        return csv_download(filename, get_csv_rows(self.__accounts))
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        return render_template("accounting/report/unapplied-accounts.html",
 | 
			
		||||
                               report=PageParams(accounts=self.__accounts))
 | 
			
		||||
@@ -22,6 +22,7 @@ from abc import ABC, abstractmethod
 | 
			
		||||
from datetime import timedelta, date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from io import StringIO
 | 
			
		||||
from urllib.parse import quote
 | 
			
		||||
 | 
			
		||||
from flask import Response
 | 
			
		||||
 | 
			
		||||
@@ -53,7 +54,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
 | 
			
		||||
        fp.seek(0)
 | 
			
		||||
        response: Response = Response(fp.read(), mimetype="text/csv")
 | 
			
		||||
        response.headers["Content-Disposition"] \
 | 
			
		||||
            = f"attachment; filename={filename}"
 | 
			
		||||
            = f"attachment; filename={quote(filename)}"
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@ from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from .option_link import OptionLink
 | 
			
		||||
from .report_type import ReportType
 | 
			
		||||
from .urls import journal_url, ledger_url, income_expenses_url, \
 | 
			
		||||
    trial_balance_url, income_statement_url, balance_sheet_url
 | 
			
		||||
    trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportChooser:
 | 
			
		||||
@@ -74,6 +74,7 @@ class ReportChooser:
 | 
			
		||||
        self.__reports.append(self.__trial_balance)
 | 
			
		||||
        self.__reports.append(self.__income_statement)
 | 
			
		||||
        self.__reports.append(self.__balance_sheet)
 | 
			
		||||
        self.__reports.append(self.__unapplied)
 | 
			
		||||
        for report in self.__reports:
 | 
			
		||||
            if report.is_active:
 | 
			
		||||
                self.current_report = report.title
 | 
			
		||||
@@ -151,6 +152,23 @@ class ReportChooser:
 | 
			
		||||
                          self.__active_report == ReportType.BALANCE_SHEET,
 | 
			
		||||
                          fa_icon="fa-solid fa-scale-balanced")
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def __unapplied(self) -> OptionLink:
 | 
			
		||||
        """Returns the unapplied original line items.
 | 
			
		||||
 | 
			
		||||
        :return: The unapplied original line items.
 | 
			
		||||
        """
 | 
			
		||||
        account: Account = self.__account
 | 
			
		||||
        if not account.is_need_offset:
 | 
			
		||||
            return OptionLink(gettext("Unapplied Original Line Items"),
 | 
			
		||||
                              unapplied_url(None),
 | 
			
		||||
                              self.__active_report == ReportType.UNAPPLIED,
 | 
			
		||||
                              fa_icon="fa-solid fa-link-slash")
 | 
			
		||||
        return OptionLink(gettext("Unapplied Original Line Items"),
 | 
			
		||||
                          unapplied_url(account),
 | 
			
		||||
                          self.__active_report == ReportType.UNAPPLIED,
 | 
			
		||||
                          fa_icon="fa-solid fa-link-slash")
 | 
			
		||||
 | 
			
		||||
    def __iter__(self) -> t.Iterator[OptionLink]:
 | 
			
		||||
        """Returns the iteration of the reports.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -34,5 +34,7 @@ class ReportType(Enum):
 | 
			
		||||
    """The income statement."""
 | 
			
		||||
    BALANCE_SHEET: str = "balance-sheet"
 | 
			
		||||
    """The balance sheet."""
 | 
			
		||||
    UNAPPLIED: str = "unapplied"
 | 
			
		||||
    """The unapplied original line items."""
 | 
			
		||||
    SEARCH: str = "search"
 | 
			
		||||
    """The search."""
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										67
									
								
								src/accounting/report/utils/unapplied.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/accounting/report/utils/unapplied.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The unapplied original line item utilities.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
from accounting.utils.cast import be
 | 
			
		||||
from accounting.utils.offset_alias import offset_alias
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_accounts_with_unapplied() -> list[Account]:
 | 
			
		||||
    """Returns the accounts with unapplied original line items.
 | 
			
		||||
 | 
			
		||||
    :return: The accounts with unapplied original line items.
 | 
			
		||||
    """
 | 
			
		||||
    offset: sa.Alias = offset_alias()
 | 
			
		||||
    net_balance: sa.Label \
 | 
			
		||||
        = (JournalEntryLineItem.amount
 | 
			
		||||
           + sa.func.sum(sa.case(
 | 
			
		||||
                (be(offset.c.is_debit == JournalEntryLineItem.is_debit),
 | 
			
		||||
                 offset.c.amount),
 | 
			
		||||
                else_=-offset.c.amount))).label("net_balance")
 | 
			
		||||
    select_unapplied: sa.Select \
 | 
			
		||||
        = sa.select(JournalEntryLineItem.id)\
 | 
			
		||||
        .join(Account)\
 | 
			
		||||
        .join(offset, be(JournalEntryLineItem.id
 | 
			
		||||
                         == offset.c.original_line_item_id),
 | 
			
		||||
              isouter=True)\
 | 
			
		||||
        .filter(Account.is_need_offset,
 | 
			
		||||
                sa.or_(sa.and_(Account.base_code.startswith("2"),
 | 
			
		||||
                               sa.not_(JournalEntryLineItem.is_debit)),
 | 
			
		||||
                       sa.and_(Account.base_code.startswith("1"),
 | 
			
		||||
                               JournalEntryLineItem.is_debit)))\
 | 
			
		||||
        .group_by(JournalEntryLineItem.id)\
 | 
			
		||||
        .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
 | 
			
		||||
 | 
			
		||||
    count_func: sa.Label \
 | 
			
		||||
        = sa.func.count(JournalEntryLineItem.id).label("count")
 | 
			
		||||
    select: sa.Select = sa.select(Account.id, count_func)\
 | 
			
		||||
        .join(JournalEntryLineItem, isouter=True)\
 | 
			
		||||
        .filter(JournalEntryLineItem.id.in_(select_unapplied))\
 | 
			
		||||
        .group_by(Account.id)\
 | 
			
		||||
        .having(count_func > 0)
 | 
			
		||||
    counts: dict[int, int] \
 | 
			
		||||
        = {x.id: x.count for x in db.session.execute(select)}
 | 
			
		||||
    accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
 | 
			
		||||
        .order_by(Account.base_code, Account.no).all()
 | 
			
		||||
    for account in accounts:
 | 
			
		||||
        account.count = counts[account.id]
 | 
			
		||||
    return accounts
 | 
			
		||||
@@ -47,9 +47,10 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the ledger.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.ledger-default",
 | 
			
		||||
                       currency=currency, account=account)
 | 
			
		||||
    if currency.code == default_currency_code() \
 | 
			
		||||
            and account.code == Account.CASH_CODE \
 | 
			
		||||
            and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.ledger-default")
 | 
			
		||||
    return url_for("accounting-report.ledger",
 | 
			
		||||
                   currency=currency, account=account,
 | 
			
		||||
                   period=period)
 | 
			
		||||
@@ -68,9 +69,6 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
 | 
			
		||||
            and account.code == options.default_ie_account_code \
 | 
			
		||||
            and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.default")
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.income-expenses-default",
 | 
			
		||||
                       currency=currency, account=account)
 | 
			
		||||
    return url_for("accounting-report.income-expenses",
 | 
			
		||||
                   currency=currency, account=account,
 | 
			
		||||
                   period=period)
 | 
			
		||||
@@ -83,9 +81,8 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the trial balance.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.trial-balance-default",
 | 
			
		||||
                       currency=currency)
 | 
			
		||||
    if currency.code == default_currency_code() and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.trial-balance-default")
 | 
			
		||||
    return url_for("accounting-report.trial-balance",
 | 
			
		||||
                   currency=currency, period=period)
 | 
			
		||||
 | 
			
		||||
@@ -97,9 +94,8 @@ def income_statement_url(currency: Currency, period: Period) -> str:
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the income statement.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.income-statement-default",
 | 
			
		||||
                       currency=currency)
 | 
			
		||||
    if currency.code == default_currency_code() and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.income-statement-default")
 | 
			
		||||
    return url_for("accounting-report.income-statement",
 | 
			
		||||
                   currency=currency, period=period)
 | 
			
		||||
 | 
			
		||||
@@ -111,8 +107,19 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the balance sheet.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.balance-sheet-default",
 | 
			
		||||
                       currency=currency)
 | 
			
		||||
    if currency.code == default_currency_code() and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.balance-sheet-default")
 | 
			
		||||
    return url_for("accounting-report.balance-sheet",
 | 
			
		||||
                   currency=currency, period=period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def unapplied_url(account: Account | None) -> str:
 | 
			
		||||
    """Returns the URL of the unapplied original line items.
 | 
			
		||||
 | 
			
		||||
    :param account: The account, or None to list the accounts with unapplied
 | 
			
		||||
        original line items.
 | 
			
		||||
    :return: The URL of the unapplied original line items.
 | 
			
		||||
    """
 | 
			
		||||
    if account is None:
 | 
			
		||||
        return url_for("accounting-report.unapplied-default")
 | 
			
		||||
    return url_for("accounting-report.unapplied", account=account)
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,8 @@ from accounting.utils.options import options
 | 
			
		||||
from accounting.utils.permission import has_permission, can_view
 | 
			
		||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
 | 
			
		||||
    IncomeStatement, BalanceSheet, Search
 | 
			
		||||
from .reports.unapplied import UnappliedOriginalLineItems
 | 
			
		||||
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
 | 
			
		||||
from .template_filters import format_amount
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("accounting-report", __name__)
 | 
			
		||||
@@ -42,10 +44,7 @@ def get_default_report() -> str | Response:
 | 
			
		||||
 | 
			
		||||
    :return: The income and expenses log in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_expenses(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()),
 | 
			
		||||
        options.default_ie_account,
 | 
			
		||||
        get_period())
 | 
			
		||||
    return get_default_income_expenses()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("journal", endpoint="journal-default")
 | 
			
		||||
@@ -81,17 +80,15 @@ def __get_journal(period: Period) -> str | Response:
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("ledger/<currency:currency>/<account:account>",
 | 
			
		||||
        endpoint="ledger-default")
 | 
			
		||||
@bp.get("ledger", endpoint="ledger-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
 | 
			
		||||
    """Returns the ledger in the default period.
 | 
			
		||||
def get_default_ledger() -> str | Response:
 | 
			
		||||
    """Returns the ledger in the default currency, cash, and default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :return: The ledger in the default period.
 | 
			
		||||
    :return: The ledger in the default currency, cash, and default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_ledger(currency, account, get_period())
 | 
			
		||||
    return __get_ledger(db.session.get(Currency, default_currency_code()),
 | 
			
		||||
                        Account.cash(), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
 | 
			
		||||
@@ -124,23 +121,21 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
 | 
			
		||||
        endpoint="income-expenses-default")
 | 
			
		||||
@bp.get("income-expenses", endpoint="income-expenses-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
def get_default_income_expenses() -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :return: The income and expenses log in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_expenses(currency, account, get_period())
 | 
			
		||||
    return __get_income_expenses(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()),
 | 
			
		||||
        options.default_ie_account,
 | 
			
		||||
        get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get(
 | 
			
		||||
    "income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
 | 
			
		||||
    endpoint="income-expenses")
 | 
			
		||||
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
 | 
			
		||||
        "<period:period>", endpoint="income-expenses")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_income_expenses(currency: Currency, account: CurrentAccount,
 | 
			
		||||
                        period: Period) -> str | Response:
 | 
			
		||||
@@ -169,16 +164,15 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("trial-balance/<currency:currency>",
 | 
			
		||||
        endpoint="trial-balance-default")
 | 
			
		||||
@bp.get("trial-balance", endpoint="trial-balance-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_trial_balance(currency: Currency) -> str | Response:
 | 
			
		||||
def get_default_trial_balance() -> str | Response:
 | 
			
		||||
    """Returns the trial balance in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :return: The trial balance in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_trial_balance(currency, get_period())
 | 
			
		||||
    return __get_trial_balance(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("trial-balance/<currency:currency>/<period:period>",
 | 
			
		||||
@@ -207,16 +201,15 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-statement/<currency:currency>",
 | 
			
		||||
        endpoint="income-statement-default")
 | 
			
		||||
@bp.get("income-statement", endpoint="income-statement-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_income_statement(currency: Currency) -> str | Response:
 | 
			
		||||
def get_default_income_statement() -> str | Response:
 | 
			
		||||
    """Returns the income statement in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :return: The income statement in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_statement(currency, get_period())
 | 
			
		||||
    return __get_income_statement(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-statement/<currency:currency>/<period:period>",
 | 
			
		||||
@@ -246,16 +239,15 @@ def __get_income_statement(currency: Currency, period: Period) \
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("balance-sheet/<currency:currency>",
 | 
			
		||||
        endpoint="balance-sheet-default")
 | 
			
		||||
@bp.get("balance-sheet", endpoint="balance-sheet-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_balance_sheet(currency: Currency) -> str | Response:
 | 
			
		||||
def get_default_balance_sheet() -> str | Response:
 | 
			
		||||
    """Returns the balance sheet in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :return: The balance sheet in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_balance_sheet(currency, get_period())
 | 
			
		||||
    return __get_balance_sheet(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("balance-sheet/<currency:currency>/<period:period>",
 | 
			
		||||
@@ -286,6 +278,34 @@ def __get_balance_sheet(currency: Currency, period: Period) \
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("unapplied", endpoint="unapplied-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_unapplied() -> str | Response:
 | 
			
		||||
    """Returns the accounts with unapplied original line items.
 | 
			
		||||
 | 
			
		||||
    :return: The accounts with unapplied original line items.
 | 
			
		||||
    """
 | 
			
		||||
    report: AccountsWithUnappliedOriginalLineItems \
 | 
			
		||||
        = AccountsWithUnappliedOriginalLineItems()
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_unapplied(account: Account) -> str | Response:
 | 
			
		||||
    """Returns the unapplied original line items.
 | 
			
		||||
 | 
			
		||||
    :param account: The Account.
 | 
			
		||||
    :return: The unapplied original line items.
 | 
			
		||||
    """
 | 
			
		||||
    report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account)
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("search", endpoint="search")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def search() -> str | Response:
 | 
			
		||||
 
 | 
			
		||||
@@ -209,11 +209,23 @@ a.accounting-report-table-row {
 | 
			
		||||
.accounting-report-table-body .accounting-amount {
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row {
 | 
			
		||||
    background-color: #f8f9fa;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
 | 
			
		||||
    background-color: #f2f2f2;
 | 
			
		||||
    background-color: #ecedee;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row:hover {
 | 
			
		||||
    background-color: rgba(0, 0, 0, 0.075);
 | 
			
		||||
    background-color: #e5e6e7;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row-danger {
 | 
			
		||||
    background-color: #f8d7da;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row-danger:nth-child(2n+1) {
 | 
			
		||||
    background-color: #eccccf;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row-danger:hover {
 | 
			
		||||
    background-color: #e5c7ca;
 | 
			
		||||
}
 | 
			
		||||
.accounting-journal-table .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
 | 
			
		||||
@@ -309,6 +321,16 @@ a.accounting-report-table-row {
 | 
			
		||||
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
.accounting-unapplied-table .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 1fr 1fr 5fr 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
.accounting-unapplied-account-table .accounting-report-table-row {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
}
 | 
			
		||||
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
 | 
			
		||||
    display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The accounting report */
 | 
			
		||||
.accounting-mobile-journal-credit {
 | 
			
		||||
@@ -343,6 +365,12 @@ a.accounting-report-table-row {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The unmatched offsets */
 | 
			
		||||
.accounting-unmatched-offset-pair-list {
 | 
			
		||||
    height: 20rem;
 | 
			
		||||
    overflow-y: scroll;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The Material Design text field (floating form control in Bootstrap) */
 | 
			
		||||
.accounting-material-text-field {
 | 
			
		||||
    position: relative;
 | 
			
		||||
 
 | 
			
		||||
@@ -123,7 +123,7 @@ class JournalEntryAccountSelector {
 | 
			
		||||
                option.setShown(false);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!isAnyMatched) {
 | 
			
		||||
        if (!isAnyMatched && this.#isShowMore) {
 | 
			
		||||
            this.#optionList.classList.add("d-none");
 | 
			
		||||
            this.#queryNoResult.classList.remove("d-none");
 | 
			
		||||
        } else {
 | 
			
		||||
 
 | 
			
		||||
@@ -51,6 +51,12 @@ First written: 2023/1/26
 | 
			
		||||
          {{ A_("Currencies") }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </li>
 | 
			
		||||
      <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>
 | 
			
		||||
      {% if accounting_can_admin() %}
 | 
			
		||||
        <li>
 | 
			
		||||
          <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">
 | 
			
		||||
 
 | 
			
		||||
@@ -50,10 +50,10 @@ First written: 2023/3/14
 | 
			
		||||
                  {% endfor %}
 | 
			
		||||
                </ul>
 | 
			
		||||
              </div>
 | 
			
		||||
              {% if line_item.balance %}
 | 
			
		||||
              {% if line_item.net_balance %}
 | 
			
		||||
                <div class="d-flex justify-content-between">
 | 
			
		||||
                  <div>{{ A_("Net balance") }}</div>
 | 
			
		||||
                  <div>{{ line_item.balance|accounting_format_amount }}</div>
 | 
			
		||||
                  <div>{{ line_item.net_balance|accounting_format_amount }}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <div class="d-flex justify-content-between">
 | 
			
		||||
 
 | 
			
		||||
@@ -42,11 +42,11 @@ First written: 2023/3/22
 | 
			
		||||
<tbody>
 | 
			
		||||
<tr>
 | 
			
		||||
  <th scope="row">{{ A_("Default Currency") }}</th>
 | 
			
		||||
  <td>{{ obj.default_currency_text }}</td>
 | 
			
		||||
  <td>{{ obj.default_currency }}</td>
 | 
			
		||||
</tr>
 | 
			
		||||
<tr>
 | 
			
		||||
  <th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
 | 
			
		||||
  <td>{{ obj.default_ie_account_code_text }}</td>
 | 
			
		||||
  <td>{{ obj.default_ie_account }}</td>
 | 
			
		||||
</tr>
 | 
			
		||||
</tbody>
 | 
			
		||||
</table>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,64 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Project
 | 
			
		||||
unapplied-accounts.html: The account list with unapplied original line items
 | 
			
		||||
 | 
			
		||||
 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_("Accounts with Unapplied Original Line Items") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  {% with 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">{{ A_("Accounts with Unapplied Original Line Items") }}</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.unapplied", account=account) }}">
 | 
			
		||||
            <div>{{ account }}</div>
 | 
			
		||||
            <div class="accounting-amount">{{ account.count }}</div>
 | 
			
		||||
          </a>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										101
									
								
								src/accounting/templates/accounting/report/unapplied.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/accounting/templates/accounting/report/unapplied.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Project
 | 
			
		||||
unapplied.html: The unapplied original line items
 | 
			
		||||
 | 
			
		||||
 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/7
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block accounting_scripts %}
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Unapplied Original Line Items of %(account)s", account=report.account.title|title) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  {% with 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 %}
 | 
			
		||||
  {% with pagination = report.pagination %}
 | 
			
		||||
    {% include "accounting/include/pagination.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
 | 
			
		||||
  <div class="d-none d-md-block accounting-report-table accounting-unapplied-table">
 | 
			
		||||
    <div class="accounting-report-table-header">
 | 
			
		||||
      <div class="accounting-report-table-row">
 | 
			
		||||
        <div>{{ A_("Date") }}</div>
 | 
			
		||||
        <div>{{ A_("Currency") }}</div>
 | 
			
		||||
        <div>{{ A_("Description") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Amount") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Net Balance") }}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="accounting-report-table-body">
 | 
			
		||||
      {% 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 }}">
 | 
			
		||||
          <div>{{ line_item.journal_entry.date|accounting_format_date }}</div>
 | 
			
		||||
          <div>{{ line_item.currency.name }}</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.net_balance|accounting_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 }}
 | 
			
		||||
          {% if line_item.currency.code != accounting_default_currency_code() %}
 | 
			
		||||
            <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
        {% if line_item.description is not none %}
 | 
			
		||||
          <div>{{ line_item.description }}</div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <span class="badge rounded-pill bg-info">{{ line_item.amount|accounting_format_amount }}</span>
 | 
			
		||||
        <span class="badge rounded-pill bg-info">{{ line_item.net_balance|accounting_format_amount }}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,40 @@
 | 
			
		||||
{#
 | 
			
		||||
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 %}
 | 
			
		||||
							
								
								
									
										107
									
								
								src/accounting/templates/accounting/unmatched-offset/list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/accounting/templates/accounting/unmatched-offset/list.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,107 @@
 | 
			
		||||
{#
 | 
			
		||||
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 }} → {{ 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 %}
 | 
			
		||||
@@ -6,10 +6,10 @@
 | 
			
		||||
#
 | 
			
		||||
msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: mia-accounting 1.0.0\n"
 | 
			
		||||
"Project-Id-Version: mia-accounting 1.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
			
		||||
"POT-Creation-Date: 2023-04-06 02:34+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-04-06 02:34+0800\n"
 | 
			
		||||
"POT-Creation-Date: 2023-04-09 01:41+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-04-09 01:41+0800\n"
 | 
			
		||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
"Language: zh_Hant\n"
 | 
			
		||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
@@ -35,22 +35,22 @@ msgstr "沒有這個貨幣。"
 | 
			
		||||
msgid "The account does not exist."
 | 
			
		||||
msgstr "沒有這個科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:562
 | 
			
		||||
#: src/accounting/models.py:581
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Cash Disbursement Journal Entry#%(id)s"
 | 
			
		||||
msgstr "現金支出傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:565
 | 
			
		||||
#: src/accounting/models.py:584
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Cash Receipt Journal Entry#%(id)s"
 | 
			
		||||
msgstr "現金收入傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:566
 | 
			
		||||
#: src/accounting/models.py:585
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Transfer Journal Entry#%(id)s"
 | 
			
		||||
msgstr "轉帳傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:699
 | 
			
		||||
#: src/accounting/models.py:714
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "%(date)s %(description)s %(amount)s"
 | 
			
		||||
msgstr "%(date)s %(description)s %(amount)s"
 | 
			
		||||
@@ -205,24 +205,24 @@ msgstr "傳票不可刪除。"
 | 
			
		||||
msgid "The journal entry is deleted successfully."
 | 
			
		||||
msgstr "傳票刪掉了"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:40
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:39
 | 
			
		||||
msgid "Please select the currency."
 | 
			
		||||
msgstr "請選擇貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:63
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:62
 | 
			
		||||
msgid "The currency must be the same as the original line item."
 | 
			
		||||
msgstr "貨幣需和原始分錄相同。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:90
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:89
 | 
			
		||||
msgid "The currency must not be changed when there is offset."
 | 
			
		||||
msgstr "抵銷過不可變更貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:99
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:98
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:773
 | 
			
		||||
msgid "Please add some line items."
 | 
			
		||||
msgstr "請加上分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:112
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:111
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:522
 | 
			
		||||
msgid "The totals of the debit and credit amounts do not match."
 | 
			
		||||
msgstr "借方貸方合計不符。 "
 | 
			
		||||
@@ -271,24 +271,24 @@ msgstr "原始分錄不可以是抵銷分錄。"
 | 
			
		||||
msgid "The account must be the same as the original line item."
 | 
			
		||||
msgstr "科目需和原始分錄相同。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:137
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:135
 | 
			
		||||
msgid "The account must not be changed when there is offset."
 | 
			
		||||
msgstr "抵銷過不可變更科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:153
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:151
 | 
			
		||||
msgid "A payable line item cannot start from debit."
 | 
			
		||||
msgstr "不可由借方新建應付款。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:169
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:167
 | 
			
		||||
msgid "A receivable line item cannot start from credit."
 | 
			
		||||
msgstr "不可由貸方新建應收款。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:180
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:178
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:436
 | 
			
		||||
msgid "Please fill in a positive amount."
 | 
			
		||||
msgstr "金額請填正數。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:222
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:220
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:442
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid ""
 | 
			
		||||
@@ -296,17 +296,17 @@ msgid ""
 | 
			
		||||
"line item."
 | 
			
		||||
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:243
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:241
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:450
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "The amount must not be less than the offset total %(total)s."
 | 
			
		||||
msgstr "金額不可低於抵銷總額 %(total)s 。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:416
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:413
 | 
			
		||||
msgid "This account is not for debit line items."
 | 
			
		||||
msgstr "科目不是借方科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:468
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:465
 | 
			
		||||
msgid "This account is not for credit line items."
 | 
			
		||||
msgstr "科目不是貸方科目。"
 | 
			
		||||
 | 
			
		||||
@@ -442,20 +442,23 @@ msgid "Brought forward"
 | 
			
		||||
msgstr "前期轉入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:407
 | 
			
		||||
#: src/accounting/report/reports/journal.py:155
 | 
			
		||||
#: src/accounting/report/reports/journal.py:158
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:366
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:137
 | 
			
		||||
#: 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/income-expenses.html:55
 | 
			
		||||
#: src/accounting/templates/accounting/report/journal.html:53
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:55
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:50
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:50
 | 
			
		||||
msgid "Date"
 | 
			
		||||
msgstr "日期"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:407
 | 
			
		||||
#: src/accounting/report/reports/journal.py:156
 | 
			
		||||
#: src/accounting/report/reports/journal.py:159
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:225
 | 
			
		||||
#: src/accounting/report/reports/unapplied_accounts.py:109
 | 
			
		||||
#: 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/report/include/toolbar-buttons.html:90
 | 
			
		||||
@@ -467,14 +470,16 @@ msgid "Account"
 | 
			
		||||
msgstr "科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:408
 | 
			
		||||
#: src/accounting/report/reports/journal.py:156
 | 
			
		||||
#: src/accounting/report/reports/journal.py:159
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:366
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:138
 | 
			
		||||
#: 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/report/income-expenses.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/report/journal.html:56
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:56
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:53
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:52
 | 
			
		||||
msgid "Description"
 | 
			
		||||
msgstr "摘要"
 | 
			
		||||
 | 
			
		||||
@@ -496,7 +501,7 @@ msgid "Balance"
 | 
			
		||||
msgstr "餘額"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:410
 | 
			
		||||
#: src/accounting/report/reports/journal.py:158
 | 
			
		||||
#: src/accounting/report/reports/journal.py:161
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:368
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
 | 
			
		||||
@@ -528,20 +533,24 @@ msgid "net income or loss for current period"
 | 
			
		||||
msgstr "本期損益"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:301
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:138
 | 
			
		||||
#: 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/unapplied.html:53
 | 
			
		||||
msgid "Amount"
 | 
			
		||||
msgstr "金額"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/journal.py:155
 | 
			
		||||
#: src/accounting/report/reports/journal.py:158
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:137
 | 
			
		||||
#: 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/journal.html:54
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:51
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:51
 | 
			
		||||
msgid "Currency"
 | 
			
		||||
msgstr "貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/journal.py:157
 | 
			
		||||
#: src/accounting/report/reports/journal.py:160
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:367
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:225
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
 | 
			
		||||
@@ -553,7 +562,7 @@ msgstr "貨幣"
 | 
			
		||||
msgid "Debit"
 | 
			
		||||
msgstr "借方"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/journal.py:157
 | 
			
		||||
#: src/accounting/report/reports/journal.py:160
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:367
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:226
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
 | 
			
		||||
@@ -565,7 +574,23 @@ msgstr "借方"
 | 
			
		||||
msgid "Credit"
 | 
			
		||||
msgstr "貸方"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:81
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:121
 | 
			
		||||
#: src/accounting/report/reports/unapplied_accounts.py:93
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:39
 | 
			
		||||
msgid "Accounts"
 | 
			
		||||
msgstr "科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:139
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:54
 | 
			
		||||
msgid "Net Balance"
 | 
			
		||||
msgstr "淨額"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/unapplied_accounts.py:109
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:47
 | 
			
		||||
msgid "Count"
 | 
			
		||||
msgstr "數量"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:82
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:98
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:40
 | 
			
		||||
#: src/accounting/templates/accounting/base-account/list.html:34
 | 
			
		||||
@@ -581,30 +606,35 @@ msgstr "貸方"
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr "搜尋"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:92
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:93
 | 
			
		||||
msgid "Income and Expenses Log"
 | 
			
		||||
msgstr "收支帳"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:105
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:106
 | 
			
		||||
msgid "Ledger"
 | 
			
		||||
msgstr "分類帳"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:117
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:118
 | 
			
		||||
msgid "Journal"
 | 
			
		||||
msgstr "日記簿"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:127
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:128
 | 
			
		||||
msgid "Trial Balance"
 | 
			
		||||
msgstr "試算表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:138
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:139
 | 
			
		||||
msgid "Income Statement"
 | 
			
		||||
msgstr "損益表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:149
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:150
 | 
			
		||||
msgid "Balance Sheet"
 | 
			
		||||
msgstr "資產負債表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:163
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:167
 | 
			
		||||
msgid "Unapplied Original Line Items"
 | 
			
		||||
msgstr "未抵銷原始分錄"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/account-form.js:206
 | 
			
		||||
msgid "Please fill in the title."
 | 
			
		||||
msgstr "請填上標題。"
 | 
			
		||||
@@ -726,6 +756,7 @@ msgstr "新增科目"
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:38
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/order.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:31
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "回上頁"
 | 
			
		||||
 | 
			
		||||
@@ -766,6 +797,7 @@ msgstr "確認刪除科目"
 | 
			
		||||
#: 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/search-modal.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:54
 | 
			
		||||
msgid "Close"
 | 
			
		||||
msgstr "關閉"
 | 
			
		||||
 | 
			
		||||
@@ -783,6 +815,7 @@ msgstr "你確定要刪掉這個科目嗎?"
 | 
			
		||||
#: 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/report/include/search-modal.html:37
 | 
			
		||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:70
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "取消"
 | 
			
		||||
 | 
			
		||||
@@ -790,6 +823,7 @@ msgstr "取消"
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:80
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:85
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:141
 | 
			
		||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:71
 | 
			
		||||
msgid "Confirm"
 | 
			
		||||
msgstr "確定"
 | 
			
		||||
 | 
			
		||||
@@ -849,6 +883,10 @@ msgstr "新增"
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:116
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:100
 | 
			
		||||
#: src/accounting/templates/accounting/report/trial-balance.html:82
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:61
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:98
 | 
			
		||||
#: src/accounting/templates/accounting/unmatched-offset/dashboard.html:37
 | 
			
		||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:104
 | 
			
		||||
msgid "There is no data."
 | 
			
		||||
msgstr "沒有資料。"
 | 
			
		||||
 | 
			
		||||
@@ -938,10 +976,6 @@ msgstr "記帳"
 | 
			
		||||
msgid "Reports"
 | 
			
		||||
msgstr "報表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:39
 | 
			
		||||
msgid "Accounts"
 | 
			
		||||
msgstr "科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:45
 | 
			
		||||
msgid "Base Accounts"
 | 
			
		||||
msgstr "基本科目"
 | 
			
		||||
@@ -950,7 +984,12 @@ msgstr "基本科目"
 | 
			
		||||
msgid "Currencies"
 | 
			
		||||
msgstr "貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:58
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:57
 | 
			
		||||
#: 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:41
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:29
 | 
			
		||||
@@ -1210,6 +1249,21 @@ msgstr "%(period)s%(currency)s%(account)s分類帳"
 | 
			
		||||
msgid "Trial Balance of %(currency)s %(period)s"
 | 
			
		||||
msgstr "%(period)s%(currency)s試算表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:24
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:41
 | 
			
		||||
msgid "Accounts with Unapplied Original Line Items"
 | 
			
		||||
msgstr "有未抵銷原始分錄的科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:28
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Unapplied Original Line Items of %(account)s"
 | 
			
		||||
msgstr "%(account)s未抵銷原始分錄"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:65
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Can match %(offset)s"
 | 
			
		||||
msgstr "可抵銷 %(offset)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:26
 | 
			
		||||
msgid "Period Chooser"
 | 
			
		||||
msgstr "選擇日期範圍"
 | 
			
		||||
@@ -1243,6 +1297,65 @@ msgstr "期間"
 | 
			
		||||
msgid "Download"
 | 
			
		||||
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
 | 
			
		||||
msgid "current assets and liabilities"
 | 
			
		||||
msgstr "流動資產與負債"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										30
									
								
								src/accounting/unmatched_offset/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/accounting/unmatched_offset/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
# 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")
 | 
			
		||||
							
								
								
									
										50
									
								
								src/accounting/unmatched_offset/queries.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/accounting/unmatched_offset/queries.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
# 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 queries for the unmatched offset management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_accounts_with_unmatched_offsets() -> list[Account]:
 | 
			
		||||
    """Returns the accounts with unmatched offsets.
 | 
			
		||||
 | 
			
		||||
    :return: The accounts with unmatched offsets, with the "count" property set
 | 
			
		||||
        to the number of unmatched offsets.
 | 
			
		||||
    """
 | 
			
		||||
    count_func: sa.Label \
 | 
			
		||||
        = sa.func.count(JournalEntryLineItem.id).label("count")
 | 
			
		||||
    select: sa.Select = sa.select(Account.id, count_func)\
 | 
			
		||||
        .select_from(Account).join(JournalEntryLineItem, isouter=True)\
 | 
			
		||||
        .filter(Account.is_need_offset,
 | 
			
		||||
                JournalEntryLineItem.original_line_item_id.is_(None),
 | 
			
		||||
                sa.or_(sa.and_(Account.base_code.startswith("2"),
 | 
			
		||||
                               JournalEntryLineItem.is_debit),
 | 
			
		||||
                       sa.and_(Account.base_code.startswith("1"),
 | 
			
		||||
                               sa.not_(JournalEntryLineItem.is_debit))))\
 | 
			
		||||
        .group_by(Account.id)\
 | 
			
		||||
        .having(count_func > 0)
 | 
			
		||||
    counts: dict[int, int] \
 | 
			
		||||
        = {x.id: x.count for x in db.session.execute(select)}
 | 
			
		||||
    accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
 | 
			
		||||
        .order_by(Account.base_code, Account.no).all()
 | 
			
		||||
    for account in accounts:
 | 
			
		||||
        account.count = counts[account.id]
 | 
			
		||||
    return accounts
 | 
			
		||||
							
								
								
									
										81
									
								
								src/accounting/unmatched_offset/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/accounting/unmatched_offset/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
# 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))
 | 
			
		||||
							
								
								
									
										129
									
								
								src/accounting/utils/offset_matcher.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/accounting/utils/offset_matcher.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,129 @@
 | 
			
		||||
# 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
 | 
			
		||||
@@ -99,12 +99,12 @@ class Options:
 | 
			
		||||
        self.__set_option("default_currency_code", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_currency_text(self) -> str:
 | 
			
		||||
        """Returns the text of the default currency code.
 | 
			
		||||
    def default_currency(self) -> Currency:
 | 
			
		||||
        """Returns the default currency.
 | 
			
		||||
 | 
			
		||||
        :return: The text of the default currency code.
 | 
			
		||||
        :return: The default currency.
 | 
			
		||||
        """
 | 
			
		||||
        return str(db.session.get(Currency, self.default_currency_code))
 | 
			
		||||
        return db.session.get(Currency, self.default_currency_code)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_ie_account_code(self) -> str:
 | 
			
		||||
@@ -123,22 +123,11 @@ class Options:
 | 
			
		||||
        """
 | 
			
		||||
        self.__set_option("default_ie_account", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_ie_account_code_text(self) -> str:
 | 
			
		||||
        """Returns the text of the default currency code.
 | 
			
		||||
 | 
			
		||||
        :return: The text of the default currency code.
 | 
			
		||||
        """
 | 
			
		||||
        code: str = self.default_ie_account_code
 | 
			
		||||
        if code == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return str(CurrentAccount.current_assets_and_liabilities())
 | 
			
		||||
        return str(CurrentAccount(Account.find_by_code(code)))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_ie_account(self) -> CurrentAccount:
 | 
			
		||||
        """Returns the default account code for the income and expenses log.
 | 
			
		||||
        """Returns the default account for the income and expenses log.
 | 
			
		||||
 | 
			
		||||
        :return: The default account code for the income and expenses log.
 | 
			
		||||
        :return: The default account for the income and expenses log.
 | 
			
		||||
        """
 | 
			
		||||
        if self.default_ie_account_code \
 | 
			
		||||
                == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										72
									
								
								src/accounting/utils/unapplied.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/accounting/utils/unapplied.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,72 @@
 | 
			
		||||
# 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
 | 
			
		||||
@@ -27,8 +27,8 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
 | 
			
		||||
    add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountData:
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,8 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
 | 
			
		||||
    add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyData:
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,8 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
 | 
			
		||||
    add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DescriptionEditorTestCase(unittest.TestCase):
 | 
			
		||||
 
 | 
			
		||||
@@ -27,11 +27,12 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
 | 
			
		||||
    add_journal_entry, match_journal_entry_detail
 | 
			
		||||
from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \
 | 
			
		||||
    get_add_form, get_unchanged_update_form, get_update_form, \
 | 
			
		||||
    match_journal_entry_detail, set_negative_amount, \
 | 
			
		||||
    remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry
 | 
			
		||||
    set_negative_amount, remove_debit_in_a_currency, \
 | 
			
		||||
    remove_credit_in_a_currency
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/journal-entries"
 | 
			
		||||
"""The URL prefix for the journal entry management."""
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,8 @@
 | 
			
		||||
"""The test for the offset.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import unittest
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
@@ -26,10 +28,9 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import match_journal_entry_detail
 | 
			
		||||
from testlib_offset import TestData, JournalEntryLineItemData, \
 | 
			
		||||
    JournalEntryData, CurrencyData
 | 
			
		||||
from testlib import Accounts, create_test_app, get_client, \
 | 
			
		||||
    match_journal_entry_detail, JournalEntryLineItemData, \
 | 
			
		||||
    JournalEntryCurrencyData, JournalEntryData, BaseTestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/journal-entries"
 | 
			
		||||
"""The URL prefix for the journal entry management."""
 | 
			
		||||
@@ -66,7 +67,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
            JournalEntryLineItem.query.delete()
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "editor")
 | 
			
		||||
        self.data: TestData = TestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        self.data: OffsetTestData = OffsetTestData(
 | 
			
		||||
            self.app, self.client, self.csrf_token)
 | 
			
		||||
 | 
			
		||||
    def test_add_receivable_offset(self) -> None:
 | 
			
		||||
        """Tests to add the receivable offset.
 | 
			
		||||
@@ -81,21 +83,21 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data: JournalEntryData = JournalEntryData(
 | 
			
		||||
            self.data.e_r_or3d.journal_entry.days, [CurrencyData(
 | 
			
		||||
            self.data.l_r_or3d.journal_entry.days, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD",
 | 
			
		||||
                [],
 | 
			
		||||
                [JournalEntryLineItemData(
 | 
			
		||||
                    Accounts.RECEIVABLE,
 | 
			
		||||
                    self.data.e_r_or1d.description, "300",
 | 
			
		||||
                    original_line_item=self.data.e_r_or1d),
 | 
			
		||||
                    self.data.l_r_or1d.description, "300",
 | 
			
		||||
                    original_line_item=self.data.l_r_or1d),
 | 
			
		||||
                 JournalEntryLineItemData(
 | 
			
		||||
                     Accounts.RECEIVABLE,
 | 
			
		||||
                     self.data.e_r_or1d.description, "100",
 | 
			
		||||
                     original_line_item=self.data.e_r_or1d),
 | 
			
		||||
                     self.data.l_r_or1d.description, "100",
 | 
			
		||||
                     original_line_item=self.data.l_r_or1d),
 | 
			
		||||
                 JournalEntryLineItemData(
 | 
			
		||||
                     Accounts.RECEIVABLE,
 | 
			
		||||
                     self.data.e_r_or3d.description, "100",
 | 
			
		||||
                     original_line_item=self.data.e_r_or3d)])])
 | 
			
		||||
                     self.data.l_r_or3d.description, "100",
 | 
			
		||||
                     original_line_item=self.data.l_r_or3d)])])
 | 
			
		||||
 | 
			
		||||
        # Non-existing original line item ID
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
@@ -107,8 +109,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_p_or1c.id
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
 | 
			
		||||
            = 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-amount"] = "100"
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
@@ -131,8 +133,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_p_of1d.id
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
 | 
			
		||||
            = str(self.data.l_p_of1d.id)
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
@@ -195,13 +197,13 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.v_r_of2
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.j_r_of2
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
 | 
			
		||||
        form: dict[str, str]
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data.days = self.data.v_r_or2.days
 | 
			
		||||
        journal_entry_data.days = self.data.j_r_or2.days
 | 
			
		||||
        journal_entry_data.currencies[0].debit[0].amount = Decimal("600")
 | 
			
		||||
        journal_entry_data.currencies[0].credit[0].amount = Decimal("600")
 | 
			
		||||
        journal_entry_data.currencies[0].debit[2].amount = Decimal("600")
 | 
			
		||||
@@ -217,8 +219,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_p_or1c.id
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
 | 
			
		||||
            = str(self.data.l_p_or1c.id)
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
 | 
			
		||||
        form["currency-1-debit-1-amount"] = "100"
 | 
			
		||||
        form["currency-1-credit-1-amount"] = "100"
 | 
			
		||||
        response = self.client.post(update_uri, data=form)
 | 
			
		||||
@@ -242,8 +244,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_p_of1d.id
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
 | 
			
		||||
            = str(self.data.l_p_of1d.id)
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
 | 
			
		||||
        response = self.client.post(update_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
@@ -308,13 +310,13 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import JournalEntry
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.v_r_or1
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.j_r_or1
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
 | 
			
		||||
        form: dict[str, str]
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data.days = self.data.v_r_of1.days
 | 
			
		||||
        journal_entry_data.days = self.data.j_r_of1.days
 | 
			
		||||
        journal_entry_data.currencies[0].debit[0].amount = Decimal("800")
 | 
			
		||||
        journal_entry_data.currencies[0].credit[0].amount = Decimal("800")
 | 
			
		||||
        journal_entry_data.currencies[0].debit[1].amount = Decimal("3.4")
 | 
			
		||||
@@ -388,7 +390,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
                JournalEntry, journal_entry_data.id)
 | 
			
		||||
            self.assertIsNotNone(journal_entry_or)
 | 
			
		||||
            journal_entry_of: JournalEntry | None = db.session.get(
 | 
			
		||||
                JournalEntry, self.data.v_r_of1.id)
 | 
			
		||||
                JournalEntry, self.data.j_r_of1.id)
 | 
			
		||||
            self.assertIsNotNone(journal_entry_of)
 | 
			
		||||
            self.assertEqual(journal_entry_or.date, journal_entry_of.date)
 | 
			
		||||
            self.assertLess(journal_entry_or.no, journal_entry_of.no)
 | 
			
		||||
@@ -405,20 +407,20 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data: JournalEntryData = JournalEntryData(
 | 
			
		||||
            self.data.e_p_or3c.journal_entry.days, [CurrencyData(
 | 
			
		||||
            self.data.l_p_or3c.journal_entry.days, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD",
 | 
			
		||||
                [JournalEntryLineItemData(
 | 
			
		||||
                    Accounts.PAYABLE,
 | 
			
		||||
                    self.data.e_p_or1c.description, "500",
 | 
			
		||||
                    original_line_item=self.data.e_p_or1c),
 | 
			
		||||
                    self.data.l_p_or1c.description, "500",
 | 
			
		||||
                    original_line_item=self.data.l_p_or1c),
 | 
			
		||||
                 JournalEntryLineItemData(
 | 
			
		||||
                     Accounts.PAYABLE,
 | 
			
		||||
                     self.data.e_p_or1c.description, "300",
 | 
			
		||||
                     original_line_item=self.data.e_p_or1c),
 | 
			
		||||
                     self.data.l_p_or1c.description, "300",
 | 
			
		||||
                     original_line_item=self.data.l_p_or1c),
 | 
			
		||||
                 JournalEntryLineItemData(
 | 
			
		||||
                     Accounts.PAYABLE,
 | 
			
		||||
                     self.data.e_p_or3c.description, "120",
 | 
			
		||||
                     original_line_item=self.data.e_p_or3c)],
 | 
			
		||||
                     self.data.l_p_or3c.description, "120",
 | 
			
		||||
                     original_line_item=self.data.l_p_or3c)],
 | 
			
		||||
                [])])
 | 
			
		||||
 | 
			
		||||
        # Non-existing original line item ID
 | 
			
		||||
@@ -431,8 +433,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_r_or1d.id
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
 | 
			
		||||
            = 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-amount"] = "100"
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
@@ -455,8 +457,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_r_of1c.id
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
 | 
			
		||||
            = str(self.data.l_r_of1c.id)
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
@@ -519,13 +521,13 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account, JournalEntry
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.v_p_of2
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.j_p_of2
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
 | 
			
		||||
        form: dict[str, str]
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data.days = self.data.v_p_or2.days
 | 
			
		||||
        journal_entry_data.days = self.data.j_p_or2.days
 | 
			
		||||
        journal_entry_data.currencies[0].debit[0].amount = Decimal("1100")
 | 
			
		||||
        journal_entry_data.currencies[0].credit[0].amount = Decimal("1100")
 | 
			
		||||
        journal_entry_data.currencies[0].debit[2].amount = Decimal("900")
 | 
			
		||||
@@ -541,8 +543,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_r_or1d.id
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
 | 
			
		||||
            = 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-amount"] = "100"
 | 
			
		||||
        form["currency-1-credit-1-amount"] = "100"
 | 
			
		||||
        response = self.client.post(update_uri, data=form)
 | 
			
		||||
@@ -566,8 +568,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.e_r_of1c.id
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
 | 
			
		||||
            = str(self.data.l_r_of1c.id)
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
 | 
			
		||||
        response = self.client.post(update_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
@@ -636,13 +638,13 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import JournalEntry
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.v_p_or1
 | 
			
		||||
        journal_entry_data: JournalEntryData = self.data.j_p_or1
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
 | 
			
		||||
        form: dict[str, str]
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data.days = self.data.v_p_of1.days
 | 
			
		||||
        journal_entry_data.days = self.data.j_p_of1.days
 | 
			
		||||
        journal_entry_data.currencies[0].debit[0].amount = Decimal("1200")
 | 
			
		||||
        journal_entry_data.currencies[0].credit[0].amount = Decimal("1200")
 | 
			
		||||
        journal_entry_data.currencies[0].debit[1].amount = Decimal("0.9")
 | 
			
		||||
@@ -716,7 +718,118 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
                JournalEntry, journal_entry_data.id)
 | 
			
		||||
            self.assertIsNotNone(journal_entry_or)
 | 
			
		||||
            journal_entry_of: JournalEntry | None = db.session.get(
 | 
			
		||||
                JournalEntry, self.data.v_p_of1.id)
 | 
			
		||||
                JournalEntry, self.data.j_p_of1.id)
 | 
			
		||||
            self.assertIsNotNone(journal_entry_of)
 | 
			
		||||
            self.assertEqual(journal_entry_or.date, journal_entry_of.date)
 | 
			
		||||
            self.assertLess(journal_entry_or.no, journal_entry_of.no)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OffsetTestData(BaseTestData):
 | 
			
		||||
    """The offset test data."""
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = self._couple(
 | 
			
		||||
            "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = self._couple(
 | 
			
		||||
            "Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = self._couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = self._couple(
 | 
			
		||||
            "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = self._couple(
 | 
			
		||||
            "Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = self._couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        # Original journal entries
 | 
			
		||||
        self.j_r_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or1d, self.l_r_or4d],
 | 
			
		||||
                [self.l_r_or1c, self.l_r_or4c])])
 | 
			
		||||
        self.j_r_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or2d, self.l_r_or3d],
 | 
			
		||||
                [self.l_r_or2c, self.l_r_or3c])])
 | 
			
		||||
        self.j_p_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or1d, self.l_p_or4d],
 | 
			
		||||
                [self.l_p_or1c, self.l_p_or4c])])
 | 
			
		||||
        self.j_p_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or2d, self.l_p_or3d],
 | 
			
		||||
                [self.l_p_or2c, self.l_p_or3c])])
 | 
			
		||||
 | 
			
		||||
        self._add_journal_entry(self.j_r_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_r_or2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or2)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = self._couple(
 | 
			
		||||
            "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of1c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = self._couple(
 | 
			
		||||
            "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = self._couple(
 | 
			
		||||
            "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = self._couple(
 | 
			
		||||
            "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of4c.original_line_item = self.l_r_or2d
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5c.original_line_item = self.l_r_or4d
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = self._couple(
 | 
			
		||||
            "Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of1d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = self._couple(
 | 
			
		||||
            "Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = self._couple(
 | 
			
		||||
            "Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = self._couple(
 | 
			
		||||
            "Phone", "400", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of4d.original_line_item = self.l_p_or2c
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d.original_line_item = self.l_p_or4c
 | 
			
		||||
 | 
			
		||||
        # Offset journal entries
 | 
			
		||||
        self.j_r_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            25, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of1d], [self.l_r_of1c])])
 | 
			
		||||
        self.j_r_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
 | 
			
		||||
                [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
 | 
			
		||||
        self.j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of5d], [self.l_r_of5c])])
 | 
			
		||||
        self.j_p_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of1d], [self.l_p_of1c])])
 | 
			
		||||
        self.j_p_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
 | 
			
		||||
                [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
 | 
			
		||||
        self.j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            5, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of5d], [self.l_p_of5c])])
 | 
			
		||||
 | 
			
		||||
        self._add_journal_entry(self.j_r_of1)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of3)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of3)
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,6 @@ from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_offset import TestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/options"
 | 
			
		||||
"""The URL prefix for the option management."""
 | 
			
		||||
@@ -68,7 +67,6 @@ class OptionTestCase(unittest.TestCase):
 | 
			
		||||
            Option.query.delete()
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "admin")
 | 
			
		||||
        self.data: TestData = TestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										415
									
								
								tests/test_report.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										415
									
								
								tests/test_report.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,415 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/9
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The test for the reports.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import unittest
 | 
			
		||||
from datetime import date
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from testlib import create_test_app, get_client, Accounts, BaseTestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting"
 | 
			
		||||
"""The URL prefix for the reports."""
 | 
			
		||||
CSV_MIME: str = "text/csv; charset=utf-8"
 | 
			
		||||
"""The MIME type of the downloaded CSV files."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportTestCase(unittest.TestCase):
 | 
			
		||||
    """The report test case."""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        """Sets up the test.
 | 
			
		||||
        This is run once per test.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            from accounting.models import BaseAccount, JournalEntry, \
 | 
			
		||||
                JournalEntryLineItem
 | 
			
		||||
            result: Result
 | 
			
		||||
            result = runner.invoke(args="init-db")
 | 
			
		||||
            self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            if BaseAccount.query.first() is None:
 | 
			
		||||
                result = runner.invoke(args="accounting-init-base")
 | 
			
		||||
                self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            result = runner.invoke(args=["accounting-init-currencies",
 | 
			
		||||
                                         "-u", "editor"])
 | 
			
		||||
            self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            result = runner.invoke(args=["accounting-init-accounts",
 | 
			
		||||
                                         "-u", "editor"])
 | 
			
		||||
            self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            JournalEntry.query.delete()
 | 
			
		||||
            JournalEntryLineItem.query.delete()
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "editor")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "nobody")
 | 
			
		||||
        ReportTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
        """Test the permission as viewer.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "viewer")
 | 
			
		||||
        ReportTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
        """Test the permission as editor.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        ReportTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=薪水")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=薪水&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
    def test_empty_db(self) -> None:
 | 
			
		||||
        """Tests the empty database.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportTestData(BaseTestData):
 | 
			
		||||
    """The report test data."""
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        today: date = date.today()
 | 
			
		||||
        year: int = today.year - 5
 | 
			
		||||
        month: int = today.month
 | 
			
		||||
        while True:
 | 
			
		||||
            j_date: date = date(year, month, 5)
 | 
			
		||||
            if j_date > today:
 | 
			
		||||
                break
 | 
			
		||||
            self._add_simple_journal_entry(
 | 
			
		||||
                (j_date - today).days, "USD",
 | 
			
		||||
                "Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE)
 | 
			
		||||
            month = month + 1
 | 
			
		||||
            if month > 12:
 | 
			
		||||
                year = year + 1
 | 
			
		||||
                month = 1
 | 
			
		||||
        self._add_simple_journal_entry(
 | 
			
		||||
            1, "USD", "Withdraw領錢", "1000", Accounts.CASH, Accounts.BANK)
 | 
			
		||||
        self._add_simple_journal_entry(
 | 
			
		||||
            0, "USD", "Dinner晚餐", "40", Accounts.MEAL, Accounts.CASH)
 | 
			
		||||
							
								
								
									
										584
									
								
								tests/test_unmatched_offset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										584
									
								
								tests/test_unmatched_offset.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,584 @@
 | 
			
		||||
# 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 test for the unmatched offsets.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import unittest
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client, Accounts, \
 | 
			
		||||
    JournalEntryCurrencyData, JournalEntryData, BaseTestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/unmatched-offsets"
 | 
			
		||||
"""The URL prefix for the unmatched offset management."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UnmatchedOffsetTestCase(unittest.TestCase):
 | 
			
		||||
    """The unmatched offset test case."""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        """Sets up the test.
 | 
			
		||||
        This is run once per test.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            from accounting.models import BaseAccount, JournalEntry, \
 | 
			
		||||
                JournalEntryLineItem
 | 
			
		||||
            result: Result
 | 
			
		||||
            result = runner.invoke(args="init-db")
 | 
			
		||||
            self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            if BaseAccount.query.first() is None:
 | 
			
		||||
                result = runner.invoke(args="accounting-init-base")
 | 
			
		||||
                self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            result = runner.invoke(args=["accounting-init-currencies",
 | 
			
		||||
                                         "-u", "editor"])
 | 
			
		||||
            self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            result = runner.invoke(args=["accounting-init-accounts",
 | 
			
		||||
                                         "-u", "editor"])
 | 
			
		||||
            self.assertEqual(result.exit_code, 0)
 | 
			
		||||
            JournalEntry.query.delete()
 | 
			
		||||
            JournalEntryLineItem.query.delete()
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "editor")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "nobody")
 | 
			
		||||
        DifferentTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        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}",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
        """Test the permission as viewer.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "viewer")
 | 
			
		||||
        DifferentTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        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}",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
        """Test the permission as editor.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        DifferentTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        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}",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
 | 
			
		||||
    def test_empty_db(self) -> None:
 | 
			
		||||
        """Test the empty database.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        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}",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
 | 
			
		||||
    def test_different(self) -> None:
 | 
			
		||||
        """Tests to match against different descriptions and amounts.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
        from accounting.utils.offset_matcher import OffsetMatcher
 | 
			
		||||
        data: DifferentTestData \
 | 
			
		||||
            = DifferentTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        account: Account | None
 | 
			
		||||
        line_item: JournalEntryLineItem | None
 | 
			
		||||
        matcher: OffsetMatcher
 | 
			
		||||
        list_uri: str
 | 
			
		||||
        match_uri: str
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        # The receivables
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.RECEIVABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_r_or1d.id, data.l_r_or2d.id,
 | 
			
		||||
                              data.l_r_or3d.id, data.l_r_or4d.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_r_of1c.id, data.l_r_of2c.id,
 | 
			
		||||
                              data.l_r_of3c.id, data.l_r_of4c.id,
 | 
			
		||||
                              data.l_r_of5c.id})
 | 
			
		||||
            self.assertEqual({(x.original_line_item.id, x.offset.id)
 | 
			
		||||
                              for x in matcher.matched_pairs},
 | 
			
		||||
                             {(data.l_r_or4d.id, data.l_r_of5c.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_of5c.id}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
 | 
			
		||||
        list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
 | 
			
		||||
        match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.RECEIVABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_r_or1d.id, data.l_r_or2d.id,
 | 
			
		||||
                              data.l_r_or3d.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_r_of1c.id, data.l_r_of2c.id,
 | 
			
		||||
                              data.l_r_of3c.id, data.l_r_of4c.id})
 | 
			
		||||
            self.assertEqual(matcher.matches, 0)
 | 
			
		||||
            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}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_r_of5c.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
 | 
			
		||||
 | 
			
		||||
        # The payables
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.PAYABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_p_or1c.id, data.l_p_or2c.id,
 | 
			
		||||
                              data.l_p_or3c.id, data.l_p_or4c.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_p_of1d.id, data.l_p_of2d.id,
 | 
			
		||||
                              data.l_p_of3d.id, data.l_p_of4d.id,
 | 
			
		||||
                              data.l_p_of5d.id})
 | 
			
		||||
            self.assertEqual({(x.original_line_item.id, x.offset.id)
 | 
			
		||||
                              for x in matcher.matched_pairs},
 | 
			
		||||
                             {(data.l_p_or4c.id, data.l_p_of5d.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_of5d.id}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
 | 
			
		||||
        list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
 | 
			
		||||
        match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.PAYABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_p_or1c.id, data.l_p_or2c.id,
 | 
			
		||||
                              data.l_p_or3c.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_p_of1d.id, data.l_p_of2d.id,
 | 
			
		||||
                              data.l_p_of3d.id, data.l_p_of4d.id})
 | 
			
		||||
            self.assertEqual(matcher.matches, 0)
 | 
			
		||||
            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}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_p_of5d.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
 | 
			
		||||
 | 
			
		||||
    def test_same(self) -> None:
 | 
			
		||||
        """Tests to match against same descriptions and amounts.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
        from accounting.utils.offset_matcher import OffsetMatcher
 | 
			
		||||
        data: SameTestData \
 | 
			
		||||
            = SameTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        account: Account | None
 | 
			
		||||
        line_item: JournalEntryLineItem | None
 | 
			
		||||
        matcher: OffsetMatcher
 | 
			
		||||
        list_uri: str
 | 
			
		||||
        match_uri: str
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        # The receivables
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.RECEIVABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_r_or1d.id, data.l_r_or3d.id,
 | 
			
		||||
                              data.l_r_or4d.id, data.l_r_or5d.id,
 | 
			
		||||
                              data.l_r_or6d.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_r_of1c.id, data.l_r_of2c.id,
 | 
			
		||||
                              data.l_r_of4c.id, data.l_r_of5c.id,
 | 
			
		||||
                              data.l_r_of6c.id})
 | 
			
		||||
            self.assertEqual({(x.original_line_item.id, x.offset.id)
 | 
			
		||||
                              for x in matcher.matched_pairs},
 | 
			
		||||
                             {(data.l_r_or1d.id, data.l_r_of2c.id),
 | 
			
		||||
                              (data.l_r_or3d.id, data.l_r_of4c.id),
 | 
			
		||||
                              (data.l_r_or4d.id, data.l_r_of6c.id)})
 | 
			
		||||
            for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
 | 
			
		||||
                                 data.l_r_of4c.id, data.l_r_of5c.id,
 | 
			
		||||
                                 data.l_r_of6c.id}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_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}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.RECEIVABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_r_or5d.id, data.l_r_or6d.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_r_of1c.id, data.l_r_of5c.id})
 | 
			
		||||
            self.assertEqual(matcher.matches, 0)
 | 
			
		||||
            for line_item_id in {data.l_r_of1c.id, data.l_r_of5c.id}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_r_of2c.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_r_or1d.id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_r_of4c.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_r_or3d.id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_r_of6c.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
 | 
			
		||||
 | 
			
		||||
        # The payables
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.PAYABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_p_or1c.id, data.l_p_or3c.id,
 | 
			
		||||
                              data.l_p_or4c.id, data.l_p_or5c.id,
 | 
			
		||||
                              data.l_p_or6c.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_p_of1d.id, data.l_p_of2d.id,
 | 
			
		||||
                              data.l_p_of4d.id, data.l_p_of5d.id,
 | 
			
		||||
                              data.l_p_of6d.id})
 | 
			
		||||
            self.assertEqual({(x.original_line_item.id, x.offset.id)
 | 
			
		||||
                              for x in matcher.matched_pairs},
 | 
			
		||||
                             {(data.l_p_or1c.id, data.l_p_of2d.id),
 | 
			
		||||
                              (data.l_p_or3c.id, data.l_p_of4d.id),
 | 
			
		||||
                              (data.l_p_or4c.id, data.l_p_of6d.id)})
 | 
			
		||||
            for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
 | 
			
		||||
                                 data.l_p_of4d.id, data.l_p_of5d.id,
 | 
			
		||||
                                 data.l_p_of6d.id}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_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}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(Accounts.PAYABLE)
 | 
			
		||||
            assert account is not None
 | 
			
		||||
            matcher = OffsetMatcher(account)
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unapplied},
 | 
			
		||||
                             {data.l_p_or5c.id, data.l_p_or6c.id})
 | 
			
		||||
            self.assertEqual({x.id for x in matcher.unmatched_offsets},
 | 
			
		||||
                             {data.l_p_of1d.id, data.l_p_of5d.id})
 | 
			
		||||
            self.assertEqual(matcher.matches, 0)
 | 
			
		||||
            for line_item_id in {data.l_p_of1d.id, data.l_p_of5d.id}:
 | 
			
		||||
                line_item = db.session.get(JournalEntryLineItem, line_item_id)
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_p_of2d.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_p_or1c.id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_p_of4d.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_p_or3c.id)
 | 
			
		||||
            line_item = db.session.get(JournalEntryLineItem, data.l_p_of6d.id)
 | 
			
		||||
            self.assertIsNotNone(line_item)
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DifferentTestData(BaseTestData):
 | 
			
		||||
    """The test data for different descriptions and amounts."""
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = self._couple(
 | 
			
		||||
            "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = self._couple(
 | 
			
		||||
            "Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = self._couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = self._couple(
 | 
			
		||||
            "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = self._couple(
 | 
			
		||||
            "Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = self._couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        # Original journal entries
 | 
			
		||||
        self.j_r_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or1d, self.l_r_or4d],
 | 
			
		||||
                [self.l_r_or1c, self.l_r_or4c])])
 | 
			
		||||
        self.j_r_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or2d, self.l_r_or3d],
 | 
			
		||||
                [self.l_r_or2c, self.l_r_or3c])])
 | 
			
		||||
        self.j_p_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or1d, self.l_p_or4d],
 | 
			
		||||
                [self.l_p_or1c, self.l_p_or4c])])
 | 
			
		||||
        self.j_p_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or2d, self.l_p_or3d],
 | 
			
		||||
                [self.l_p_or2c, self.l_p_or3c])])
 | 
			
		||||
 | 
			
		||||
        self._add_journal_entry(self.j_r_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_r_or2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or2)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = self._couple(
 | 
			
		||||
            "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = self._couple(
 | 
			
		||||
            "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = self._couple(
 | 
			
		||||
            "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = self._couple(
 | 
			
		||||
            "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = self._couple(
 | 
			
		||||
            "Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = self._couple(
 | 
			
		||||
            "Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = self._couple(
 | 
			
		||||
            "Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = self._couple(
 | 
			
		||||
            "Phone", "400", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
 | 
			
		||||
        # Offset journal entries
 | 
			
		||||
        self.j_r_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            25, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of1d], [self.l_r_of1c])])
 | 
			
		||||
        self.j_r_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
 | 
			
		||||
                [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
 | 
			
		||||
        self.j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of5d], [self.l_r_of5c])])
 | 
			
		||||
        self.j_p_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of1d], [self.l_p_of1c])])
 | 
			
		||||
        self.j_p_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
 | 
			
		||||
                [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
 | 
			
		||||
        self.j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            5, [JournalEntryCurrencyData(
 | 
			
		||||
                "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_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of3)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of3)
 | 
			
		||||
        self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SameTestData(BaseTestData):
 | 
			
		||||
    """The test data with same descriptions and amounts."""
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = self._add_simple_journal_entry(
 | 
			
		||||
            60, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = self._add_simple_journal_entry(
 | 
			
		||||
            50, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = self._add_simple_journal_entry(
 | 
			
		||||
            40, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = self._add_simple_journal_entry(
 | 
			
		||||
            30, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or5d, self.l_r_or5c = self._add_simple_journal_entry(
 | 
			
		||||
            20, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or6d, self.l_r_or6c = self._add_simple_journal_entry(
 | 
			
		||||
            10, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = self._add_simple_journal_entry(
 | 
			
		||||
            60, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = self._add_simple_journal_entry(
 | 
			
		||||
            50, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = self._add_simple_journal_entry(
 | 
			
		||||
            40, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = self._add_simple_journal_entry(
 | 
			
		||||
            30, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or5d, self.l_p_or5c = self._add_simple_journal_entry(
 | 
			
		||||
            20, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or6d, self.l_p_or6c = self._add_simple_journal_entry(
 | 
			
		||||
            10, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, False)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = self._add_simple_journal_entry(
 | 
			
		||||
            65, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = self._couple(
 | 
			
		||||
            "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3c.original_line_item = self.l_r_or2d
 | 
			
		||||
        j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of3d], [self.l_r_of3c])])
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of6d, self.l_r_of6c = self._add_simple_journal_entry(
 | 
			
		||||
            15, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = self._add_simple_journal_entry(
 | 
			
		||||
            65, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = self._couple(
 | 
			
		||||
            "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d.original_line_item = self.l_p_or2c
 | 
			
		||||
        j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of3d], [self.l_p_of3c])])
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of6d, self.l_p_of6c = self._add_simple_journal_entry(
 | 
			
		||||
            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_p_of3)
 | 
			
		||||
							
								
								
									
										275
									
								
								tests/testlib.py
									
									
									
									
									
								
							
							
						
						
									
										275
									
								
								tests/testlib.py
									
									
									
									
									
								
							@@ -17,12 +17,19 @@
 | 
			
		||||
"""The common test libraries.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
 | 
			
		||||
from _decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask, render_template_string
 | 
			
		||||
 | 
			
		||||
from test_site import create_app
 | 
			
		||||
from test_site import create_app, db
 | 
			
		||||
 | 
			
		||||
TEST_SERVER: str = "https://testserver"
 | 
			
		||||
"""The test server URI."""
 | 
			
		||||
@@ -117,3 +124,269 @@ def set_locale(client: httpx.Client, csrf_token: str,
 | 
			
		||||
                                                 "next": "/next"})
 | 
			
		||||
    assert response.status_code == 302
 | 
			
		||||
    assert response.headers["Location"] == "/next"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
 | 
			
		||||
    """Adds a transfer journal entry.
 | 
			
		||||
 | 
			
		||||
    :param client: The client.
 | 
			
		||||
    :param form: The form data.
 | 
			
		||||
    :return: The newly-added journal entry ID.
 | 
			
		||||
    """
 | 
			
		||||
    prefix: str = "/accounting/journal-entries"
 | 
			
		||||
    journal_entry_type: str = "transfer"
 | 
			
		||||
    if len({x for x in form if "-debit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "receipt"
 | 
			
		||||
    elif len({x for x in form if "-credit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "disbursement"
 | 
			
		||||
    store_uri = f"{prefix}/store/{journal_entry_type}"
 | 
			
		||||
    response: httpx.Response = client.post(store_uri, data=form)
 | 
			
		||||
    assert response.status_code == 302
 | 
			
		||||
    return match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def match_journal_entry_detail(location: str) -> int:
 | 
			
		||||
    """Validates if the redirect location is the journal entry detail, and
 | 
			
		||||
    returns the journal entry ID on success.
 | 
			
		||||
 | 
			
		||||
    :param location: The redirect location.
 | 
			
		||||
    :return: The journal entry ID.
 | 
			
		||||
    :raise AssertionError: When the location is not the journal entry detail.
 | 
			
		||||
    """
 | 
			
		||||
    m: re.Match = re.match(
 | 
			
		||||
        r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
 | 
			
		||||
    assert m is not None
 | 
			
		||||
    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()
 | 
			
		||||
 
 | 
			
		||||
@@ -18,11 +18,10 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from secrets import randbelow
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
@@ -375,39 +374,6 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
 | 
			
		||||
    return m.group(1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
 | 
			
		||||
    """Adds a transfer journal entry.
 | 
			
		||||
 | 
			
		||||
    :param client: The client.
 | 
			
		||||
    :param form: The form data.
 | 
			
		||||
    :return: The newly-added journal entry ID.
 | 
			
		||||
    """
 | 
			
		||||
    prefix: str = "/accounting/journal-entries"
 | 
			
		||||
    journal_entry_type: str = "transfer"
 | 
			
		||||
    if len({x for x in form if "-debit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "receipt"
 | 
			
		||||
    elif len({x for x in form if "-credit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "disbursement"
 | 
			
		||||
    store_uri = f"{prefix}/store/{journal_entry_type}"
 | 
			
		||||
    response: httpx.Response = client.post(store_uri, data=form)
 | 
			
		||||
    assert response.status_code == 302
 | 
			
		||||
    return match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def match_journal_entry_detail(location: str) -> int:
 | 
			
		||||
    """Validates if the redirect location is the journal entry detail, and
 | 
			
		||||
    returns the journal entry ID on success.
 | 
			
		||||
 | 
			
		||||
    :param location: The redirect location.
 | 
			
		||||
    :return: The journal entry ID.
 | 
			
		||||
    :raise AssertionError: When the location is not the journal entry detail.
 | 
			
		||||
    """
 | 
			
		||||
    m: re.Match = re.match(
 | 
			
		||||
        r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
 | 
			
		||||
    assert m is not None
 | 
			
		||||
    return int(m.group(1))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_negative_amount(form: dict[str, str]) -> None:
 | 
			
		||||
    """Sets a negative amount in the form data, keeping the balance.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,315 +0,0 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
 | 
			
		||||
 | 
			
		||||
#  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 test libraries for the offset test cases.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts
 | 
			
		||||
from testlib_journal_entry import match_journal_entry_detail
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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 CurrencyData:
 | 
			
		||||
    """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[CurrencyData]):
 | 
			
		||||
        """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[CurrencyData] = 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 TestData:
 | 
			
		||||
    """The 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
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.e_r_or1d, self.e_r_or1c = couple(
 | 
			
		||||
            "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
 | 
			
		||||
        self.e_r_or2d, self.e_r_or2c = couple(
 | 
			
		||||
            "Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.e_r_or3d, self.e_r_or3c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.e_r_or4d, self.e_r_or4c = couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.e_p_or1d, self.e_p_or1c = couple(
 | 
			
		||||
            "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
 | 
			
		||||
        self.e_p_or2d, self.e_p_or2c = couple(
 | 
			
		||||
            "Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
        self.e_p_or3d, self.e_p_or3c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.e_p_or4d, self.e_p_or4c = couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        # Original journal entries
 | 
			
		||||
        self.v_r_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d],
 | 
			
		||||
                              [self.e_r_or1c, self.e_r_or4c])])
 | 
			
		||||
        self.v_r_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d],
 | 
			
		||||
                              [self.e_r_or2c, self.e_r_or3c])])
 | 
			
		||||
        self.v_p_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d],
 | 
			
		||||
                              [self.e_p_or1c, self.e_p_or4c])])
 | 
			
		||||
        self.v_p_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d],
 | 
			
		||||
                              [self.e_p_or2c, self.e_p_or3c])])
 | 
			
		||||
 | 
			
		||||
        self.__add_journal_entry(self.v_r_or1)
 | 
			
		||||
        self.__add_journal_entry(self.v_r_or2)
 | 
			
		||||
        self.__add_journal_entry(self.v_p_or1)
 | 
			
		||||
        self.__add_journal_entry(self.v_p_or2)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.e_r_of1d, self.e_r_of1c = couple(
 | 
			
		||||
            "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.e_r_of1c.original_line_item = self.e_r_or1d
 | 
			
		||||
        self.e_r_of2d, self.e_r_of2c = couple(
 | 
			
		||||
            "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.e_r_of2c.original_line_item = self.e_r_or1d
 | 
			
		||||
        self.e_r_of3d, self.e_r_of3c = couple(
 | 
			
		||||
            "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.e_r_of3c.original_line_item = self.e_r_or1d
 | 
			
		||||
        self.e_r_of4d, self.e_r_of4c = couple(
 | 
			
		||||
            "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.e_r_of4c.original_line_item = self.e_r_or2d
 | 
			
		||||
        self.e_r_of5d, self.e_r_of5c = couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.e_r_of5c.original_line_item = self.e_r_or4d
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.e_p_of1d, self.e_p_of1c = couple(
 | 
			
		||||
            "Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.e_p_of1d.original_line_item = self.e_p_or1c
 | 
			
		||||
        self.e_p_of2d, self.e_p_of2c = couple(
 | 
			
		||||
            "Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.e_p_of2d.original_line_item = self.e_p_or1c
 | 
			
		||||
        self.e_p_of3d, self.e_p_of3c = couple(
 | 
			
		||||
            "Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.e_p_of3d.original_line_item = self.e_p_or1c
 | 
			
		||||
        self.e_p_of4d, self.e_p_of4c = couple(
 | 
			
		||||
            "Phone", "400", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.e_p_of4d.original_line_item = self.e_p_or2c
 | 
			
		||||
        self.e_p_of5d, self.e_p_of5c = couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.e_p_of5d.original_line_item = self.e_p_or4c
 | 
			
		||||
 | 
			
		||||
        # Offset journal entries
 | 
			
		||||
        self.v_r_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])])
 | 
			
		||||
        self.v_r_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD",
 | 
			
		||||
                              [self.e_r_of2d, self.e_r_of3d, self.e_r_of4d],
 | 
			
		||||
                              [self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])])
 | 
			
		||||
        self.v_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])])
 | 
			
		||||
        self.v_p_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])])
 | 
			
		||||
        self.v_p_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [CurrencyData("USD",
 | 
			
		||||
                              [self.e_p_of2d, self.e_p_of3d, self.e_p_of4d],
 | 
			
		||||
                              [self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])])
 | 
			
		||||
        self.v_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])])
 | 
			
		||||
 | 
			
		||||
        self.__add_journal_entry(self.v_r_of1)
 | 
			
		||||
        self.__add_journal_entry(self.v_r_of2)
 | 
			
		||||
        self.__add_journal_entry(self.v_r_of3)
 | 
			
		||||
        self.__add_journal_entry(self.v_p_of1)
 | 
			
		||||
        self.__add_journal_entry(self.v_p_of2)
 | 
			
		||||
        self.__add_journal_entry(self.v_p_of3)
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
		Reference in New Issue
	
	Block a user