Compare commits
	
		
			177 Commits
		
	
	
		
			v0.5.0
			...
			d8afadda02
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d8afadda02 | |||
| c8e1270d8f | |||
| 2a78799404 | |||
| 863d7a9368 | |||
| 6fd37b21d9 | |||
| bbf3ee3320 | |||
| b60cc7902d | |||
| 623313b58a | |||
| d0d2d77a2e | |||
| 494faeffea | |||
| 871a5fd1d8 | |||
| e615ad2690 | |||
| da92a0b42c | |||
| 678d0aa773 | |||
| 9248ba7e3b | |||
| 446087b212 | |||
| a42e7d13a2 | |||
| a82f5091f1 | |||
| 3455827c09 | |||
| 5dccf99a55 | |||
| 8818b46e01 | |||
| 2f3ad99467 | |||
| 592910187b | |||
| cb7a0d377f | |||
| 79175285f8 | |||
| fef474977c | |||
| fa1a55cd3d | |||
| 2253ec7e6d | |||
| 32aa532548 | |||
| 56138f7de3 | |||
| 21ef944259 | |||
| 760f1c2877 | |||
| e377eac407 | |||
| 77787eee9f | |||
| 03265a1232 | |||
| 079dc1ab6d | |||
| d4fe91ec4a | |||
| acc5b4d6ea | |||
| 19a93cb4c3 | |||
| 116089d1d2 | |||
| 50dd6078c7 | |||
| 9a4531b26c | |||
| b1af1d7425 | |||
| 8f909965a9 | |||
| e26af6f3fc | |||
| 02fffc3400 | |||
| d7d6929bf2 | |||
| e4cc61552e | |||
| d18dd7d4d2 | |||
| 3251660092 | |||
| c1235608d8 | |||
| 25c45b16ae | |||
| 78f570b81b | |||
| 5db13393cc | |||
| 1e286fbeba | |||
| d4b3fe67b9 | |||
| 5d0757c845 | |||
| b69a519904 | |||
| 122b7b059c | |||
| 4977847dd8 | |||
| b9b197ea27 | |||
| 884e37fe1b | |||
| cc6a73211e | |||
| 2299b86d0f | |||
| 6d293a1aac | |||
| a2311aee24 | |||
| 5571c0d01f | |||
| 98e1bad413 | |||
| 7ff52d99e6 | |||
| cc440a4110 | |||
| f5149a0c37 | |||
| ca928636fd | |||
| 4a8297d594 | |||
| 915e4408e1 | |||
| fd9eac06f6 | |||
| 403942dfc0 | |||
| 35dc513760 | |||
| 01861f0b6a | |||
| 8c10f1e96a | |||
| 5f7fc0b8e8 | |||
| 700c179774 | |||
| cabe02f7d0 | |||
| 5ceb9f2e83 | |||
| fe1c7669b6 | |||
| 4eac10981f | |||
| c869bccc04 | |||
| 61c111db69 | |||
| 34f63c1cdf | |||
| a643d9e811 | |||
| 2239ddfad1 | |||
| 12fbe36b9a | |||
| 46e34bb89a | |||
| c9453d3023 | |||
| fc766724c4 | |||
| 38c394c0af | |||
| 67e2b06d37 | |||
| be10a8d99e | |||
| fbeec600b7 | |||
| 1a54592d4c | |||
| 94a527caf2 | |||
| 0a1bbbdd47 | |||
| 82b63e4bd4 | |||
| e1d1aff0c1 | |||
| 2e5f9ee01f | |||
| f901a0020f | |||
| fc2be75c3b | |||
| 96c131940b | |||
| b9435a255b | |||
| 56045f0faf | |||
| 08d1e60238 | |||
| d88b3ac770 | |||
| 40e329d37f | |||
| 23a0721d8d | |||
| 2b2c665eb6 | |||
| 954173a2c2 | |||
| 91e6dc6668 | |||
| e9d8a8fcd8 | |||
| 4c84686395 | |||
| 61fd1849ed | |||
| a67158f8f6 | |||
| 5c6bfd8b49 | |||
| d9ecf51c6d | |||
| 5d31eb9172 | |||
| fadce244c5 | |||
| cbe7c6ca6d | |||
| b03938fb2e | |||
| 8061a23fdc | |||
| cd8a480cd0 | |||
| b8b87714eb | |||
| bf2f96621d | |||
| 2d771f04be | |||
| 3a12472d4b | |||
| d5a686a5d8 | |||
| 690f89e29a | |||
| 82a6a53dc4 | |||
| cdd31b1047 | |||
| 5bad949cfa | |||
| 3826646d06 | |||
| 74071e8997 | |||
| 3ce34803f3 | |||
| 232f73172f | |||
| ff1bb7142b | |||
| 7155bf635a | |||
| c306ff8009 | |||
| b344abce06 | |||
| b3c666c872 | |||
| 6a671cac84 | |||
| fe87c3a7de | |||
| 2013f8cbd9 | |||
| 2325842471 | |||
| c80e58b049 | |||
| be0ae5eba4 | |||
| 2b84f64554 | |||
| 0a658a76e8 | |||
| 50dc79d865 | |||
| 8e5377a416 | |||
| 4299fd6fbd | |||
| 1d6a53f7cd | |||
| bb2993b0c0 | |||
| f6946c1165 | |||
| 8e219d8006 | |||
| 53565eb9e6 | |||
| 965e78d8ad | |||
| 74b81d3e23 | |||
| a0fba6387f | |||
| d28bdf2064 | |||
| edf0c00e34 | |||
| 107d161379 | |||
| f2c184f769 | |||
| b45986ecfc | |||
| a2c2452ec5 | |||
| 5194258b48 | |||
| 3fe7eb41ac | |||
| 7fb9e2f0a1 | |||
| 1d443f7b76 | |||
| 6ad4fba9cd | |||
| 3dda6531b5 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -38,3 +38,4 @@ excludes
 | 
				
			|||||||
*.mo
 | 
					*.mo
 | 
				
			||||||
zh_Hans
 | 
					zh_Hans
 | 
				
			||||||
test_temp.py
 | 
					test_temp.py
 | 
				
			||||||
 | 
					dummy.js
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,6 +15,7 @@
 | 
				
			|||||||
#  See the License for the specific language governing permissions and
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
#  limitations under the License.
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					exclude src/accounting/static/js/dummy.js
 | 
				
			||||||
include src/accounting/translations/*
 | 
					include src/accounting/translations/*
 | 
				
			||||||
include src/accounting/translations/*/LC_MESSAGES/*
 | 
					include src/accounting/translations/*/LC_MESSAGES/*
 | 
				
			||||||
include docs/*
 | 
					include docs/*
 | 
				
			||||||
@@ -22,7 +23,9 @@ include docs/source/*
 | 
				
			|||||||
include docs/source/_static/*
 | 
					include docs/source/_static/*
 | 
				
			||||||
include docs/source/_templates/*
 | 
					include docs/source/_templates/*
 | 
				
			||||||
include tests/*
 | 
					include tests/*
 | 
				
			||||||
 | 
					exclude tests/test_temp.py
 | 
				
			||||||
include tests/test_site/*
 | 
					include tests/test_site/*
 | 
				
			||||||
 | 
					include tests/test_site/static/*
 | 
				
			||||||
include tests/test_site/templates/*
 | 
					include tests/test_site/templates/*
 | 
				
			||||||
include tests/test_site/translations/*
 | 
					include tests/test_site/translations/*
 | 
				
			||||||
include tests/test_site/translations/*/LC_MESSAGES/*
 | 
					include tests/test_site/translations/*/LC_MESSAGES/*
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										45
									
								
								docs/source/accounting.journal_entry.forms.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								docs/source/accounting.journal_entry.forms.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
				
			|||||||
 | 
					accounting.journal\_entry.forms package
 | 
				
			||||||
 | 
					=======================================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Submodules
 | 
				
			||||||
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.forms.currency module
 | 
				
			||||||
 | 
					-----------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.forms.currency
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.forms.journal\_entry module
 | 
				
			||||||
 | 
					-----------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.forms.journal_entry
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.forms.line\_item module
 | 
				
			||||||
 | 
					-------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.forms.line_item
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.forms.reorder module
 | 
				
			||||||
 | 
					----------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.forms.reorder
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Module contents
 | 
				
			||||||
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.forms
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
							
								
								
									
										46
									
								
								docs/source/accounting.journal_entry.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								docs/source/accounting.journal_entry.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
				
			|||||||
 | 
					accounting.journal\_entry package
 | 
				
			||||||
 | 
					=================================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Subpackages
 | 
				
			||||||
 | 
					-----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. toctree::
 | 
				
			||||||
 | 
					   :maxdepth: 4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   accounting.journal_entry.forms
 | 
				
			||||||
 | 
					   accounting.journal_entry.utils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Submodules
 | 
				
			||||||
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.converters module
 | 
				
			||||||
 | 
					-------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.converters
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.template\_filters module
 | 
				
			||||||
 | 
					--------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.template_filters
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.views module
 | 
				
			||||||
 | 
					--------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.views
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Module contents
 | 
				
			||||||
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
							
								
								
									
										53
									
								
								docs/source/accounting.journal_entry.utils.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.journal_entry.utils.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
				
			|||||||
 | 
					accounting.journal\_entry.utils package
 | 
				
			||||||
 | 
					=======================================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Submodules
 | 
				
			||||||
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.utils.account\_option module
 | 
				
			||||||
 | 
					------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.utils.account_option
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.utils.description\_editor module
 | 
				
			||||||
 | 
					----------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.utils.description_editor
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.utils.offset\_alias module
 | 
				
			||||||
 | 
					----------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.utils.offset_alias
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.utils.operators module
 | 
				
			||||||
 | 
					------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.utils.operators
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.journal\_entry.utils.original\_line\_items module
 | 
				
			||||||
 | 
					------------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.utils.original_line_items
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Module contents
 | 
				
			||||||
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.journal_entry.utils
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
@@ -10,8 +10,8 @@ Subpackages
 | 
				
			|||||||
   accounting.account
 | 
					   accounting.account
 | 
				
			||||||
   accounting.base_account
 | 
					   accounting.base_account
 | 
				
			||||||
   accounting.currency
 | 
					   accounting.currency
 | 
				
			||||||
 | 
					   accounting.journal_entry
 | 
				
			||||||
   accounting.report
 | 
					   accounting.report
 | 
				
			||||||
   accounting.transaction
 | 
					 | 
				
			||||||
   accounting.utils
 | 
					   accounting.utils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Submodules
 | 
					Submodules
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,61 +0,0 @@
 | 
				
			|||||||
accounting.transaction package
 | 
					 | 
				
			||||||
==============================
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Submodules
 | 
					 | 
				
			||||||
----------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.transaction.converters module
 | 
					 | 
				
			||||||
----------------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.transaction.converters
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.transaction.forms module
 | 
					 | 
				
			||||||
-----------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.transaction.forms
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.transaction.operators module
 | 
					 | 
				
			||||||
---------------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.transaction.operators
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.transaction.summary\_editor module
 | 
					 | 
				
			||||||
---------------------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.transaction.summary_editor
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.transaction.template\_filters module
 | 
					 | 
				
			||||||
-----------------------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.transaction.template_filters
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.transaction.views module
 | 
					 | 
				
			||||||
-----------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.transaction.views
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Module contents
 | 
					 | 
				
			||||||
---------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.transaction
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
@@ -4,6 +4,14 @@ accounting.utils package
 | 
				
			|||||||
Submodules
 | 
					Submodules
 | 
				
			||||||
----------
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.utils.cast module
 | 
				
			||||||
 | 
					----------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.utils.cast
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
accounting.utils.flash\_errors module
 | 
					accounting.utils.flash\_errors module
 | 
				
			||||||
-------------------------------------
 | 
					-------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -12,6 +20,14 @@ accounting.utils.flash\_errors module
 | 
				
			|||||||
   :undoc-members:
 | 
					   :undoc-members:
 | 
				
			||||||
   :show-inheritance:
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.utils.journal\_entry\_types module
 | 
				
			||||||
 | 
					---------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.utils.journal_entry_types
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
accounting.utils.next\_uri module
 | 
					accounting.utils.next\_uri module
 | 
				
			||||||
---------------------------------
 | 
					---------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -60,14 +76,6 @@ accounting.utils.strip\_text module
 | 
				
			|||||||
   :undoc-members:
 | 
					   :undoc-members:
 | 
				
			||||||
   :show-inheritance:
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
accounting.utils.txn\_types module
 | 
					 | 
				
			||||||
----------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.utils.txn_types
 | 
					 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.utils.user module
 | 
					accounting.utils.user module
 | 
				
			||||||
----------------------------
 | 
					----------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
 | 
				
			|||||||
project = 'Mia! Accounting Flask'
 | 
					project = 'Mia! Accounting Flask'
 | 
				
			||||||
copyright = '2023, imacat'
 | 
					copyright = '2023, imacat'
 | 
				
			||||||
author = 'imacat'
 | 
					author = 'imacat'
 | 
				
			||||||
release = '0.4.0'
 | 
					release = '0.8.0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# -- General configuration ---------------------------------------------------
 | 
					# -- General configuration ---------------------------------------------------
 | 
				
			||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 | 
					# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[metadata]
 | 
					[metadata]
 | 
				
			||||||
name = mia-accounting-flask
 | 
					name = mia-accounting-flask
 | 
				
			||||||
version = 0.5.0
 | 
					version = 0.8.0
 | 
				
			||||||
author = imacat
 | 
					author = imacat
 | 
				
			||||||
author_email = imacat@mail.imacat.idv.tw
 | 
					author_email = imacat@mail.imacat.idv.tw
 | 
				
			||||||
description = The Mia! Accounting Flask project.
 | 
					description = The Mia! Accounting Flask project.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,13 +17,12 @@
 | 
				
			|||||||
"""The accounting application.
 | 
					"""The accounting application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
import typing as t
 | 
					 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask import Flask, Blueprint
 | 
					from flask import Flask, Blueprint
 | 
				
			||||||
from flask_sqlalchemy import SQLAlchemy
 | 
					from flask_sqlalchemy import SQLAlchemy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounting.utils.user import AbstractUserUtils
 | 
					from accounting.utils.user import UserUtilityInterface
 | 
				
			||||||
 | 
					
 | 
				
			||||||
db: SQLAlchemy = SQLAlchemy()
 | 
					db: SQLAlchemy = SQLAlchemy()
 | 
				
			||||||
"""The database instance."""
 | 
					"""The database instance."""
 | 
				
			||||||
@@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data"
 | 
				
			|||||||
"""The data directory."""
 | 
					"""The data directory."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
					def init_app(app: Flask, user_utils: UserUtilityInterface,
 | 
				
			||||||
             url_prefix: str = "/accounting",
 | 
					             url_prefix: str = "/accounting") -> None:
 | 
				
			||||||
             can_view_func: t.Callable[[], bool] | None = None,
 | 
					 | 
				
			||||||
             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
					 | 
				
			||||||
    """Initialize the application.
 | 
					    """Initialize the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param app: The Flask application.
 | 
					    :param app: The Flask application.
 | 
				
			||||||
    :param user_utils: The user utilities.
 | 
					    :param user_utils: The user utilities.
 | 
				
			||||||
    :param url_prefix: The URL prefix of the accounting application.
 | 
					    :param url_prefix: The URL prefix of the accounting application.
 | 
				
			||||||
    :param can_view_func: A callback that returns whether the current user can
 | 
					 | 
				
			||||||
        view the accounting data.
 | 
					 | 
				
			||||||
    :param can_edit_func: A callback that returns whether the current user can
 | 
					 | 
				
			||||||
        edit the accounting data.
 | 
					 | 
				
			||||||
    :return: None.
 | 
					    :return: None.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    # The database instance must be set before loading everything
 | 
					    # The database instance must be set before loading everything
 | 
				
			||||||
@@ -73,7 +66,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
				
			|||||||
    locale.init_app(app, bp)
 | 
					    locale.init_app(app, bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from .utils import permission
 | 
					    from .utils import permission
 | 
				
			||||||
    permission.init_app(bp, can_view_func, can_edit_func)
 | 
					    permission.init_app(bp, user_utils)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from .utils import next_uri
 | 
					    from .utils import next_uri
 | 
				
			||||||
    next_uri.init_app(bp)
 | 
					    next_uri.init_app(bp)
 | 
				
			||||||
@@ -87,8 +80,8 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
				
			|||||||
    from . import currency
 | 
					    from . import currency
 | 
				
			||||||
    currency.init_app(app, bp)
 | 
					    currency.init_app(app, bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from . import transaction
 | 
					    from . import journal_entry
 | 
				
			||||||
    transaction.init_app(app, bp)
 | 
					    journal_entry.init_app(app, bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from . import report
 | 
					    from . import report
 | 
				
			||||||
    report.init_app(app, bp)
 | 
					    report.init_app(app, bp)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import re
 | 
					 | 
				
			||||||
from secrets import randbelow
 | 
					from secrets import randbelow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import click
 | 
					import click
 | 
				
			||||||
@@ -30,7 +29,7 @@ from accounting.utils.user import has_user, get_user_pk
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
AccountData = tuple[int, str, int, str, str, str, bool]
 | 
					AccountData = tuple[int, str, int, str, str, str, bool]
 | 
				
			||||||
"""The format of the account data, as a list of (ID, base account code, number,
 | 
					"""The format of the account data, as a list of (ID, base account code, number,
 | 
				
			||||||
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
 | 
					English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
 | 
					def __validate_username(ctx: click.core.Context, param: click.core.Option,
 | 
				
			||||||
@@ -93,14 +92,37 @@ def init_accounts_command(username: str) -> None:
 | 
				
			|||||||
    data: list[AccountData] = []
 | 
					    data: list[AccountData] = []
 | 
				
			||||||
    for base in bases_to_add:
 | 
					    for base in bases_to_add:
 | 
				
			||||||
        l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
 | 
					        l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
 | 
				
			||||||
        is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
 | 
					        is_need_offset: bool = __is_need_offset(base.code)
 | 
				
			||||||
            else False
 | 
					 | 
				
			||||||
        data.append((get_new_id(), base.code, 1, base.title_l10n,
 | 
					        data.append((get_new_id(), base.code, 1, base.title_l10n,
 | 
				
			||||||
                     l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
 | 
					                     l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
 | 
				
			||||||
    __add_accounting_accounts(data, creator_pk)
 | 
					    __add_accounting_accounts(data, creator_pk)
 | 
				
			||||||
    click.echo(F"{len(data)} added.  Accounting accounts initialized.")
 | 
					    click.echo(F"{len(data)} added.  Accounting accounts initialized.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def __is_need_offset(base_code: str) -> bool:
 | 
				
			||||||
 | 
					    """Checks that whether journal entry line items in the account need offset.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param base_code: The code of the base account.
 | 
				
			||||||
 | 
					    :return: True if journal entry line items in the account need offset, or
 | 
				
			||||||
 | 
					        False otherwise.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    # Assets
 | 
				
			||||||
 | 
					    if base_code[0] == "1":
 | 
				
			||||||
 | 
					        if base_code[:3] in {"113", "114", "118", "184"}:
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        if base_code in {"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"}:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					    # Only assets and liabilities need offset
 | 
				
			||||||
 | 
					    return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
 | 
					def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
 | 
				
			||||||
        -> None:
 | 
					        -> None:
 | 
				
			||||||
    """Adds the accounts.
 | 
					    """Adds the accounts.
 | 
				
			||||||
@@ -113,7 +135,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
 | 
				
			|||||||
                                       base_code=x[1],
 | 
					                                       base_code=x[1],
 | 
				
			||||||
                                       no=x[2],
 | 
					                                       no=x[2],
 | 
				
			||||||
                                       title_l10n=x[3],
 | 
					                                       title_l10n=x[3],
 | 
				
			||||||
                                       is_offset_needed=x[6],
 | 
					                                       is_need_offset=x[6],
 | 
				
			||||||
                                       created_by_id=creator_pk,
 | 
					                                       created_by_id=creator_pk,
 | 
				
			||||||
                                       updated_by_id=creator_pk)
 | 
					                                       updated_by_id=creator_pk)
 | 
				
			||||||
                               for x in data]
 | 
					                               for x in data]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -53,6 +53,20 @@ class BaseAccountAvailable:
 | 
				
			|||||||
                "The base account is not available."))
 | 
					                "The base account is not available."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NoOffsetNominalAccount:
 | 
				
			||||||
 | 
					    """The validator to check nominal account is not to be offset."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: BooleanField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, AccountForm)
 | 
				
			||||||
 | 
					        if not field.data:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if form.base_code.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if form.base_code.data[0] not in {"1", "2", "3"}:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "A nominal account does not need offset."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountForm(FlaskForm):
 | 
					class AccountForm(FlaskForm):
 | 
				
			||||||
    """The form to create or edit an account."""
 | 
					    """The form to create or edit an account."""
 | 
				
			||||||
    base_code = StringField(
 | 
					    base_code = StringField(
 | 
				
			||||||
@@ -66,8 +80,9 @@ class AccountForm(FlaskForm):
 | 
				
			|||||||
        filters=[strip_text],
 | 
					        filters=[strip_text],
 | 
				
			||||||
        validators=[DataRequired(lazy_gettext("Please fill in the title"))])
 | 
					        validators=[DataRequired(lazy_gettext("Please fill in the title"))])
 | 
				
			||||||
    """The title."""
 | 
					    """The title."""
 | 
				
			||||||
    is_offset_needed = BooleanField()
 | 
					    is_need_offset = BooleanField(
 | 
				
			||||||
    """Whether the the entries of this account need offset."""
 | 
					        validators=[NoOffsetNominalAccount()])
 | 
				
			||||||
 | 
					    """Whether the the journal entry line items of this account need offset."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def populate_obj(self, obj: Account) -> None:
 | 
					    def populate_obj(self, obj: Account) -> None:
 | 
				
			||||||
        """Populates the form data into an account object.
 | 
					        """Populates the form data into an account object.
 | 
				
			||||||
@@ -87,7 +102,10 @@ class AccountForm(FlaskForm):
 | 
				
			|||||||
            obj.base_code = self.base_code.data
 | 
					            obj.base_code = self.base_code.data
 | 
				
			||||||
            obj.no = count + 1
 | 
					            obj.no = count + 1
 | 
				
			||||||
        obj.title = self.title.data
 | 
					        obj.title = self.title.data
 | 
				
			||||||
        obj.is_offset_needed = self.is_offset_needed.data
 | 
					        if self.base_code.data[0] in {"1", "2", "3"}:
 | 
				
			||||||
 | 
					            obj.is_need_offset = self.is_need_offset.data
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            obj.is_need_offset = False
 | 
				
			||||||
        if is_new:
 | 
					        if is_new:
 | 
				
			||||||
            current_user_pk: int = get_current_user_pk()
 | 
					            current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
            obj.created_by_id = current_user_pk
 | 
					            obj.created_by_id = current_user_pk
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -40,15 +40,15 @@ def get_account_query() -> list[Account]:
 | 
				
			|||||||
    conditions: list[sa.BinaryExpression] = []
 | 
					    conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
    for k in keywords:
 | 
					    for k in keywords:
 | 
				
			||||||
        l10n: list[AccountL10n] = AccountL10n.query\
 | 
					        l10n: list[AccountL10n] = AccountL10n.query\
 | 
				
			||||||
            .filter(AccountL10n.title.contains(k)).all()
 | 
					            .filter(AccountL10n.title.icontains(k)).all()
 | 
				
			||||||
        l10n_matches: set[str] = {x.account_id for x in l10n}
 | 
					        l10n_matches: set[str] = {x.account_id for x in l10n}
 | 
				
			||||||
        sub_conditions: list[sa.BinaryExpression] \
 | 
					        sub_conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [Account.base_code.contains(k),
 | 
					            = [Account.base_code.contains(k),
 | 
				
			||||||
               Account.title_l10n.contains(k),
 | 
					               Account.title_l10n.icontains(k),
 | 
				
			||||||
               code.contains(k),
 | 
					               code.contains(k),
 | 
				
			||||||
               Account.id.in_(l10n_matches)]
 | 
					               Account.id.in_(l10n_matches)]
 | 
				
			||||||
        if k in gettext("Need offset"):
 | 
					        if k in gettext("Needs Offset"):
 | 
				
			||||||
            sub_conditions.append(Account.is_offset_needed)
 | 
					            sub_conditions.append(Account.is_need_offset)
 | 
				
			||||||
        conditions.append(sa.or_(*sub_conditions))
 | 
					        conditions.append(sa.or_(*sub_conditions))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return Account.query.filter(*conditions)\
 | 
					    return Account.query.filter(*conditions)\
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
 | 
				
			|||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.locale import lazy_gettext
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
from accounting.models import Account, BaseAccount
 | 
					from accounting.models import Account, BaseAccount
 | 
				
			||||||
 | 
					from accounting.utils.cast import s
 | 
				
			||||||
from accounting.utils.flash_errors import flash_form_errors
 | 
					from accounting.utils.flash_errors import flash_form_errors
 | 
				
			||||||
from accounting.utils.next_uri import inherit_next, or_next
 | 
					from accounting.utils.next_uri import inherit_next, or_next
 | 
				
			||||||
from accounting.utils.pagination import Pagination
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
@@ -86,7 +87,7 @@ def add_account() -> redirect:
 | 
				
			|||||||
    form.populate_obj(account)
 | 
					    form.populate_obj(account)
 | 
				
			||||||
    db.session.add(account)
 | 
					    db.session.add(account)
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    flash(lazy_gettext("The account is added successfully"), "success")
 | 
					    flash(s(lazy_gettext("The account is added successfully")), "success")
 | 
				
			||||||
    return redirect(inherit_next(__get_detail_uri(account)))
 | 
					    return redirect(inherit_next(__get_detail_uri(account)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect:
 | 
				
			|||||||
    with db.session.no_autoflush:
 | 
					    with db.session.no_autoflush:
 | 
				
			||||||
        form.populate_obj(account)
 | 
					        form.populate_obj(account)
 | 
				
			||||||
    if not account.is_modified:
 | 
					    if not account.is_modified:
 | 
				
			||||||
        flash(lazy_gettext("The account was not modified."), "success")
 | 
					        flash(s(lazy_gettext("The account was not modified.")), "success")
 | 
				
			||||||
        return redirect(inherit_next(__get_detail_uri(account)))
 | 
					        return redirect(inherit_next(__get_detail_uri(account)))
 | 
				
			||||||
    account.updated_by_id = get_current_user_pk()
 | 
					    account.updated_by_id = get_current_user_pk()
 | 
				
			||||||
    account.updated_at = sa.func.now()
 | 
					    account.updated_at = sa.func.now()
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    flash(lazy_gettext("The account is updated successfully."), "success")
 | 
					    flash(s(lazy_gettext("The account is updated successfully.")), "success")
 | 
				
			||||||
    return redirect(inherit_next(__get_detail_uri(account)))
 | 
					    return redirect(inherit_next(__get_detail_uri(account)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -156,10 +157,13 @@ def delete_account(account: Account) -> redirect:
 | 
				
			|||||||
    :return: The redirection to the account list on success, or the account
 | 
					    :return: The redirection to the account list on success, or the account
 | 
				
			||||||
        detail on error.
 | 
					        detail on error.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					    if not account.can_delete:
 | 
				
			||||||
 | 
					        flash(s(lazy_gettext("The account cannot be deleted.")), "error")
 | 
				
			||||||
 | 
					        return redirect(inherit_next(__get_detail_uri(account)))
 | 
				
			||||||
    account.delete()
 | 
					    account.delete()
 | 
				
			||||||
    sort_accounts_in(account.base_code, account.id)
 | 
					    sort_accounts_in(account.base_code, account.id)
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    flash(lazy_gettext("The account is deleted successfully."), "success")
 | 
					    flash(s(lazy_gettext("The account is deleted successfully.")), "success")
 | 
				
			||||||
    return redirect(or_next(__get_list_uri()))
 | 
					    return redirect(or_next(__get_list_uri()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -186,10 +190,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
 | 
				
			|||||||
    form: AccountReorderForm = AccountReorderForm(base)
 | 
					    form: AccountReorderForm = AccountReorderForm(base)
 | 
				
			||||||
    form.save_order()
 | 
					    form.save_order()
 | 
				
			||||||
    if not form.is_modified:
 | 
					    if not form.is_modified:
 | 
				
			||||||
        flash(lazy_gettext("The order was not modified."), "success")
 | 
					        flash(s(lazy_gettext("The order was not modified.")), "success")
 | 
				
			||||||
        return redirect(or_next(__get_list_uri()))
 | 
					        return redirect(or_next(__get_list_uri()))
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    flash(lazy_gettext("The order is updated successfully."), "success")
 | 
					    flash(s(lazy_gettext("The order is updated successfully.")), "success")
 | 
				
			||||||
    return redirect(or_next(__get_list_uri()))
 | 
					    return redirect(or_next(__get_list_uri()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
 | 
				
			|||||||
    conditions: list[sa.BinaryExpression] = []
 | 
					    conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
    for k in keywords:
 | 
					    for k in keywords:
 | 
				
			||||||
        l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
 | 
					        l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
 | 
				
			||||||
            .filter(BaseAccountL10n.title.contains(k)).all()
 | 
					            .filter(BaseAccountL10n.title.icontains(k)).all()
 | 
				
			||||||
        l10n_matches: set[str] = {x.account_code for x in l10n}
 | 
					        l10n_matches: set[str] = {x.account_code for x in l10n}
 | 
				
			||||||
        conditions.append(sa.or_(BaseAccount.code.contains(k),
 | 
					        conditions.append(sa.or_(BaseAccount.code.contains(k),
 | 
				
			||||||
                                 BaseAccount.title_l10n.contains(k),
 | 
					                                 BaseAccount.title_l10n.icontains(k),
 | 
				
			||||||
                                 BaseAccount.code.in_(l10n_matches)))
 | 
					                                 BaseAccount.code.in_(l10n_matches)))
 | 
				
			||||||
    return BaseAccount.query.filter(*conditions)\
 | 
					    return BaseAccount.query.filter(*conditions)\
 | 
				
			||||||
        .order_by(BaseAccount.code).all()
 | 
					        .order_by(BaseAccount.code).all()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,8 +17,6 @@
 | 
				
			|||||||
"""The forms for the currency management.
 | 
					"""The forms for the currency management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
from __future__ import annotations
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from flask_wtf import FlaskForm
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
from wtforms import StringField, ValidationError
 | 
					from wtforms import StringField, ValidationError
 | 
				
			||||||
from wtforms.validators import DataRequired, Regexp, NoneOf
 | 
					from wtforms.validators import DataRequired, Regexp, NoneOf
 | 
				
			||||||
@@ -30,22 +28,24 @@ from accounting.utils.strip_text import strip_text
 | 
				
			|||||||
from accounting.utils.user import get_current_user_pk
 | 
					from accounting.utils.user import get_current_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CodeUnique:
 | 
				
			||||||
 | 
					    """The validator to check if the code is unique."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, CurrencyForm)
 | 
				
			||||||
 | 
					        if field.data == "":
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if form.obj_code is not None and form.obj_code == field.data:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if db.session.get(Currency, field.data) is not None:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "Code conflicts with another currency."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CurrencyForm(FlaskForm):
 | 
					class CurrencyForm(FlaskForm):
 | 
				
			||||||
    """The form to create or edit a currency."""
 | 
					    """The form to create or edit a currency."""
 | 
				
			||||||
    CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
 | 
					    CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
 | 
				
			||||||
    """The reserved codes that are not available."""
 | 
					    """The reserved codes that are not available."""
 | 
				
			||||||
 | 
					 | 
				
			||||||
    class CodeUnique:
 | 
					 | 
				
			||||||
        """The validator to check if the code is unique."""
 | 
					 | 
				
			||||||
        def __call__(self, form: CurrencyForm, field: StringField) -> None:
 | 
					 | 
				
			||||||
            if field.data == "":
 | 
					 | 
				
			||||||
                return
 | 
					 | 
				
			||||||
            if form.obj_code is not None and form.obj_code == field.data:
 | 
					 | 
				
			||||||
                return
 | 
					 | 
				
			||||||
            if db.session.get(Currency, field.data) is not None:
 | 
					 | 
				
			||||||
                raise ValidationError(lazy_gettext(
 | 
					 | 
				
			||||||
                    "Code conflicts with another currency."))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    code = StringField(
 | 
					    code = StringField(
 | 
				
			||||||
        filters=[strip_text],
 | 
					        filters=[strip_text],
 | 
				
			||||||
        validators=[DataRequired(lazy_gettext("Please fill in the code.")),
 | 
					        validators=[DataRequired(lazy_gettext("Please fill in the code.")),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
 | 
				
			|||||||
    conditions: list[sa.BinaryExpression] = []
 | 
					    conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
    for k in keywords:
 | 
					    for k in keywords:
 | 
				
			||||||
        l10n: list[CurrencyL10n] = CurrencyL10n.query\
 | 
					        l10n: list[CurrencyL10n] = CurrencyL10n.query\
 | 
				
			||||||
            .filter(CurrencyL10n.name.contains(k)).all()
 | 
					            .filter(CurrencyL10n.name.icontains(k)).all()
 | 
				
			||||||
        l10n_matches: set[str] = {x.account_code for x in l10n}
 | 
					        l10n_matches: set[str] = {x.account_code for x in l10n}
 | 
				
			||||||
        conditions.append(sa.or_(Currency.code.contains(k),
 | 
					        conditions.append(sa.or_(Currency.code.icontains(k),
 | 
				
			||||||
                                 Currency.name_l10n.contains(k),
 | 
					                                 Currency.name_l10n.icontains(k),
 | 
				
			||||||
                                 Currency.code.in_(l10n_matches)))
 | 
					                                 Currency.code.in_(l10n_matches)))
 | 
				
			||||||
    return Currency.query.filter(*conditions)\
 | 
					    return Currency.query.filter(*conditions)\
 | 
				
			||||||
        .order_by(Currency.code).all()
 | 
					        .order_by(Currency.code).all()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
 | 
				
			|||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.locale import lazy_gettext
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
from accounting.models import Currency
 | 
					from accounting.models import Currency
 | 
				
			||||||
 | 
					from accounting.utils.cast import s
 | 
				
			||||||
from accounting.utils.flash_errors import flash_form_errors
 | 
					from accounting.utils.flash_errors import flash_form_errors
 | 
				
			||||||
from accounting.utils.next_uri import inherit_next, or_next
 | 
					from accounting.utils.next_uri import inherit_next, or_next
 | 
				
			||||||
from accounting.utils.pagination import Pagination
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
@@ -88,7 +89,7 @@ def add_currency() -> redirect:
 | 
				
			|||||||
    form.populate_obj(currency)
 | 
					    form.populate_obj(currency)
 | 
				
			||||||
    db.session.add(currency)
 | 
					    db.session.add(currency)
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    flash(lazy_gettext("The currency is added successfully"), "success")
 | 
					    flash(s(lazy_gettext("The currency is added successfully.")), "success")
 | 
				
			||||||
    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
					    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect:
 | 
				
			|||||||
    with db.session.no_autoflush:
 | 
					    with db.session.no_autoflush:
 | 
				
			||||||
        form.populate_obj(currency)
 | 
					        form.populate_obj(currency)
 | 
				
			||||||
    if not currency.is_modified:
 | 
					    if not currency.is_modified:
 | 
				
			||||||
        flash(lazy_gettext("The currency was not modified."), "success")
 | 
					        flash(s(lazy_gettext("The currency was not modified.")), "success")
 | 
				
			||||||
        return redirect(inherit_next(__get_detail_uri(currency)))
 | 
					        return redirect(inherit_next(__get_detail_uri(currency)))
 | 
				
			||||||
    currency.updated_by_id = get_current_user_pk()
 | 
					    currency.updated_by_id = get_current_user_pk()
 | 
				
			||||||
    currency.updated_at = sa.func.now()
 | 
					    currency.updated_at = sa.func.now()
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    flash(lazy_gettext("The currency is updated successfully."), "success")
 | 
					    flash(s(lazy_gettext("The currency is updated successfully.")), "success")
 | 
				
			||||||
    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
					    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -159,9 +160,12 @@ def delete_currency(currency: Currency) -> redirect:
 | 
				
			|||||||
    :return: The redirection to the currency list on success, or the currency
 | 
					    :return: The redirection to the currency list on success, or the currency
 | 
				
			||||||
        detail on error.
 | 
					        detail on error.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					    if not currency.can_delete:
 | 
				
			||||||
 | 
					        flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
 | 
				
			||||||
 | 
					        return redirect(inherit_next(__get_detail_uri(currency)))
 | 
				
			||||||
    currency.delete()
 | 
					    currency.delete()
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    flash(lazy_gettext("The currency is deleted successfully."), "success")
 | 
					    flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
 | 
				
			||||||
    return redirect(or_next(url_for("accounting.currency.list")))
 | 
					    return redirect(or_next(url_for("accounting.currency.list")))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -182,4 +186,3 @@ def __get_detail_uri(currency: Currency) -> str:
 | 
				
			|||||||
    :return: The detail URI of the currency.
 | 
					    :return: The detail URI of the currency.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    return url_for("accounting.currency.detail", currency=currency)
 | 
					    return url_for("accounting.currency.detail", currency=currency)
 | 
				
			||||||
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
#  See the License for the specific language governing permissions and
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
#  limitations under the License.
 | 
					#  limitations under the License.
 | 
				
			||||||
"""The transaction management.
 | 
					"""The journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
from flask import Flask, Blueprint
 | 
					from flask import Flask, Blueprint
 | 
				
			||||||
@@ -27,11 +27,11 @@ def init_app(app: Flask, bp: Blueprint) -> None:
 | 
				
			|||||||
    :param bp: The blueprint of the accounting application.
 | 
					    :param bp: The blueprint of the accounting application.
 | 
				
			||||||
    :return: None.
 | 
					    :return: None.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    from .converters import TransactionConverter, TransactionTypeConverter, \
 | 
					    from .converters import JournalEntryConverter, JournalEntryTypeConverter, \
 | 
				
			||||||
        DateConverter
 | 
					        DateConverter
 | 
				
			||||||
    app.url_map.converters["transaction"] = TransactionConverter
 | 
					    app.url_map.converters["journalEntry"] = JournalEntryConverter
 | 
				
			||||||
    app.url_map.converters["transactionType"] = TransactionTypeConverter
 | 
					    app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter
 | 
				
			||||||
    app.url_map.converters["date"] = DateConverter
 | 
					    app.url_map.converters["date"] = DateConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from .views import bp as transaction_bp
 | 
					    from .views import bp as journal_entry_bp
 | 
				
			||||||
    bp.register_blueprint(transaction_bp, url_prefix="/transactions")
 | 
					    bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries")
 | 
				
			||||||
							
								
								
									
										107
									
								
								src/accounting/journal_entry/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/accounting/journal_entry/converters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,107 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The path converters for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from datetime import date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import abort
 | 
				
			||||||
 | 
					from sqlalchemy.orm import selectinload
 | 
				
			||||||
 | 
					from werkzeug.routing import BaseConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.models import JournalEntry, JournalEntryLineItem
 | 
				
			||||||
 | 
					from accounting.utils.journal_entry_types import JournalEntryType
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class JournalEntryConverter(BaseConverter):
 | 
				
			||||||
 | 
					    """The journal entry converter to convert the journal entry ID from and to
 | 
				
			||||||
 | 
					    the corresponding journal entry in the routes."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_python(self, value: str) -> JournalEntry:
 | 
				
			||||||
 | 
					        """Converts a journal entry ID to a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The journal entry ID.
 | 
				
			||||||
 | 
					        :return: The corresponding journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        journal_entry: JournalEntry | None = JournalEntry.query\
 | 
				
			||||||
 | 
					            .join(JournalEntryLineItem)\
 | 
				
			||||||
 | 
					            .filter(JournalEntry.id == value)\
 | 
				
			||||||
 | 
					            .options(selectinload(JournalEntry.line_items)
 | 
				
			||||||
 | 
					                     .selectinload(JournalEntryLineItem.offsets)
 | 
				
			||||||
 | 
					                     .selectinload(JournalEntryLineItem.journal_entry))\
 | 
				
			||||||
 | 
					            .first()
 | 
				
			||||||
 | 
					        if journal_entry is None:
 | 
				
			||||||
 | 
					            abort(404)
 | 
				
			||||||
 | 
					        return journal_entry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_url(self, value: JournalEntry) -> str:
 | 
				
			||||||
 | 
					        """Converts a journal entry to its ID.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The journal entry.
 | 
				
			||||||
 | 
					        :return: The ID.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return str(value.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class JournalEntryTypeConverter(BaseConverter):
 | 
				
			||||||
 | 
					    """The journal entry converter to convert the journal entry type ID from
 | 
				
			||||||
 | 
					    and to the corresponding journal entry type in the routes."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_python(self, value: str) -> JournalEntryType:
 | 
				
			||||||
 | 
					        """Converts a journal entry ID to a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The journal entry ID.
 | 
				
			||||||
 | 
					        :return: The corresponding journal entry type.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        type_dict: dict[str, JournalEntryType] \
 | 
				
			||||||
 | 
					            = {x.value: x for x in JournalEntryType}
 | 
				
			||||||
 | 
					        journal_entry_type: JournalEntryType | None = type_dict.get(value)
 | 
				
			||||||
 | 
					        if journal_entry_type is None:
 | 
				
			||||||
 | 
					            abort(404)
 | 
				
			||||||
 | 
					        return journal_entry_type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_url(self, value: JournalEntryType) -> str:
 | 
				
			||||||
 | 
					        """Converts a journal entry type to its ID.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The journal entry type.
 | 
				
			||||||
 | 
					        :return: The ID.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return str(value.value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DateConverter(BaseConverter):
 | 
				
			||||||
 | 
					    """The date converter to convert the ISO date from and to the
 | 
				
			||||||
 | 
					    corresponding date in the routes."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_python(self, value: str) -> date:
 | 
				
			||||||
 | 
					        """Converts an ISO date to a date.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The ISO date.
 | 
				
			||||||
 | 
					        :return: The corresponding date.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return date.fromisoformat(value)
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            abort(404)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_url(self, value: date) -> str:
 | 
				
			||||||
 | 
					        """Converts a date to its ISO date.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The date.
 | 
				
			||||||
 | 
					        :return: The ISO date.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return value.isoformat()
 | 
				
			||||||
							
								
								
									
										22
									
								
								src/accounting/journal_entry/forms/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/accounting/journal_entry/forms/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The forms for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from .reorder import sort_journal_entries_in, JournalEntryReorderForm
 | 
				
			||||||
 | 
					from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
 | 
				
			||||||
 | 
					    CashDisbursementJournalEntryForm, TransferJournalEntryForm
 | 
				
			||||||
							
								
								
									
										303
									
								
								src/accounting/journal_entry/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								src/accounting/journal_entry/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,303 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The currency sub-forms for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from decimal import Decimal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask_babel import LazyString
 | 
				
			||||||
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
 | 
					from wtforms import StringField, ValidationError, FieldList, IntegerField, \
 | 
				
			||||||
 | 
					    BooleanField, FormField
 | 
				
			||||||
 | 
					from wtforms.validators import DataRequired
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import Currency, JournalEntryLineItem
 | 
				
			||||||
 | 
					from accounting.journal_entry.utils.offset_alias import offset_alias
 | 
				
			||||||
 | 
					from accounting.utils.cast import be
 | 
				
			||||||
 | 
					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."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyExists:
 | 
				
			||||||
 | 
					    """The validator to check if the account exists."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if db.session.get(Currency, field.data) is None:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The currency does not exist."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SameCurrencyAsOriginalLineItems:
 | 
				
			||||||
 | 
					    """The validator to check if the currency is the same as the
 | 
				
			||||||
 | 
					    original line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, CurrencyForm)
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        original_line_item_id: set[int] \
 | 
				
			||||||
 | 
					            = {x.original_line_item_id.data
 | 
				
			||||||
 | 
					               for x in form.line_items
 | 
				
			||||||
 | 
					               if x.original_line_item_id.data is not None}
 | 
				
			||||||
 | 
					        if len(original_line_item_id) == 0:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        original_line_item_currency_codes: set[str] = set(db.session.scalars(
 | 
				
			||||||
 | 
					            sa.select(JournalEntryLineItem.currency_code)
 | 
				
			||||||
 | 
					            .filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
 | 
				
			||||||
 | 
					        for currency_code in original_line_item_currency_codes:
 | 
				
			||||||
 | 
					            if field.data != currency_code:
 | 
				
			||||||
 | 
					                raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                    "The currency must be the same as the"
 | 
				
			||||||
 | 
					                    " original line item."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class KeepCurrencyWhenHavingOffset:
 | 
				
			||||||
 | 
					    """The validator to check if the currency is the same when there is
 | 
				
			||||||
 | 
					    offset."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, CurrencyForm)
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        offset: sa.Alias = offset_alias()
 | 
				
			||||||
 | 
					        original_line_items: list[JournalEntryLineItem]\
 | 
				
			||||||
 | 
					            = JournalEntryLineItem.query\
 | 
				
			||||||
 | 
					            .join(offset, be(JournalEntryLineItem.id
 | 
				
			||||||
 | 
					                             == offset.c.original_line_item_id),
 | 
				
			||||||
 | 
					                  isouter=True)\
 | 
				
			||||||
 | 
					            .filter(JournalEntryLineItem.id
 | 
				
			||||||
 | 
					                    .in_({x.id.data for x in form.line_items
 | 
				
			||||||
 | 
					                          if x.id.data is not None}))\
 | 
				
			||||||
 | 
					            .group_by(JournalEntryLineItem.id,
 | 
				
			||||||
 | 
					                      JournalEntryLineItem.currency_code)\
 | 
				
			||||||
 | 
					            .having(sa.func.count(offset.c.id) > 0).all()
 | 
				
			||||||
 | 
					        for original_line_item in original_line_items:
 | 
				
			||||||
 | 
					            if original_line_item.currency_code != field.data:
 | 
				
			||||||
 | 
					                raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                    "The currency must not be changed when there is offset."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NeedSomeLineItems:
 | 
				
			||||||
 | 
					    """The validator to check if there is any line item sub-form."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: FieldList) -> None:
 | 
				
			||||||
 | 
					        if len(field) == 0:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "Please add some line items."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class IsBalanced:
 | 
				
			||||||
 | 
					    """The validator to check that the total amount of the debit and credit
 | 
				
			||||||
 | 
					    line items are equal."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: BooleanField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, TransferCurrencyForm)
 | 
				
			||||||
 | 
					        if len(form.debit) == 0 or len(form.credit) == 0:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if form.debit_total != form.credit_total:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The totals of the debit and credit amounts do not match."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyForm(FlaskForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a currency in a journal entry."""
 | 
				
			||||||
 | 
					    no = IntegerField()
 | 
				
			||||||
 | 
					    """The order in the journal entry."""
 | 
				
			||||||
 | 
					    code = StringField()
 | 
				
			||||||
 | 
					    """The currency code."""
 | 
				
			||||||
 | 
					    whole_form = BooleanField()
 | 
				
			||||||
 | 
					    """The pseudo field for the whole form validators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def line_items(self) -> list[LineItemForm]:
 | 
				
			||||||
 | 
					        """Returns the line item sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The line item sub-forms.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        line_item_forms: list[LineItemForm] = []
 | 
				
			||||||
 | 
					        if isinstance(self, CashReceiptCurrencyForm):
 | 
				
			||||||
 | 
					            line_item_forms.extend([x.form for x in self.credit])
 | 
				
			||||||
 | 
					        elif isinstance(self, CashDisbursementCurrencyForm):
 | 
				
			||||||
 | 
					            line_item_forms.extend([x.form for x in self.debit])
 | 
				
			||||||
 | 
					        elif isinstance(self, TransferCurrencyForm):
 | 
				
			||||||
 | 
					            line_item_forms.extend([x.form for x in self.debit])
 | 
				
			||||||
 | 
					            line_item_forms.extend([x.form for x in self.credit])
 | 
				
			||||||
 | 
					        return line_item_forms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_code_locked(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the currency code should not be changed.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the currency code should not be changed, or False
 | 
				
			||||||
 | 
					            otherwise
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        line_item_forms: list[LineItemForm] = self.line_items
 | 
				
			||||||
 | 
					        original_line_item_id: set[int] \
 | 
				
			||||||
 | 
					            = {x.original_line_item_id.data for x in line_item_forms
 | 
				
			||||||
 | 
					               if x.original_line_item_id.data is not None}
 | 
				
			||||||
 | 
					        if len(original_line_item_id) > 0:
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        line_item_id: set[int] = {x.id.data for x in line_item_forms
 | 
				
			||||||
 | 
					                                  if x.id.data is not None}
 | 
				
			||||||
 | 
					        select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
 | 
				
			||||||
 | 
					            .filter(JournalEntryLineItem.original_line_item_id
 | 
				
			||||||
 | 
					                    .in_(line_item_id))
 | 
				
			||||||
 | 
					        return db.session.scalar(select) > 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CashReceiptCurrencyForm(CurrencyForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a currency in a
 | 
				
			||||||
 | 
					    cash receipt journal entry."""
 | 
				
			||||||
 | 
					    no = IntegerField()
 | 
				
			||||||
 | 
					    """The order in the journal entry."""
 | 
				
			||||||
 | 
					    code = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[CURRENCY_REQUIRED,
 | 
				
			||||||
 | 
					                    CurrencyExists(),
 | 
				
			||||||
 | 
					                    SameCurrencyAsOriginalLineItems(),
 | 
				
			||||||
 | 
					                    KeepCurrencyWhenHavingOffset()])
 | 
				
			||||||
 | 
					    """The currency code."""
 | 
				
			||||||
 | 
					    credit = FieldList(FormField(CreditLineItemForm),
 | 
				
			||||||
 | 
					                       validators=[NeedSomeLineItems()])
 | 
				
			||||||
 | 
					    """The credit line items."""
 | 
				
			||||||
 | 
					    whole_form = BooleanField()
 | 
				
			||||||
 | 
					    """The pseudo field for the whole form validators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def credit_total(self) -> Decimal:
 | 
				
			||||||
 | 
					        """Returns the total amount of the credit line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The total amount of the credit line items.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return sum([x.amount.data for x in self.credit
 | 
				
			||||||
 | 
					                    if x.amount.data is not None])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def credit_errors(self) -> list[str | LazyString]:
 | 
				
			||||||
 | 
					        """Returns the credit line item errors, without the errors in their
 | 
				
			||||||
 | 
					        sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [x for x in self.credit.errors
 | 
				
			||||||
 | 
					                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CashDisbursementCurrencyForm(CurrencyForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a currency in a
 | 
				
			||||||
 | 
					    cash disbursement journal entry."""
 | 
				
			||||||
 | 
					    no = IntegerField()
 | 
				
			||||||
 | 
					    """The order in the journal entry."""
 | 
				
			||||||
 | 
					    code = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[CURRENCY_REQUIRED,
 | 
				
			||||||
 | 
					                    CurrencyExists(),
 | 
				
			||||||
 | 
					                    SameCurrencyAsOriginalLineItems(),
 | 
				
			||||||
 | 
					                    KeepCurrencyWhenHavingOffset()])
 | 
				
			||||||
 | 
					    """The currency code."""
 | 
				
			||||||
 | 
					    debit = FieldList(FormField(DebitLineItemForm),
 | 
				
			||||||
 | 
					                      validators=[NeedSomeLineItems()])
 | 
				
			||||||
 | 
					    """The debit line items."""
 | 
				
			||||||
 | 
					    whole_form = BooleanField()
 | 
				
			||||||
 | 
					    """The pseudo field for the whole form validators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def debit_total(self) -> Decimal:
 | 
				
			||||||
 | 
					        """Returns the total amount of the debit line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The total amount of the debit line items.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return sum([x.amount.data for x in self.debit
 | 
				
			||||||
 | 
					                    if x.amount.data is not None])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def debit_errors(self) -> list[str | LazyString]:
 | 
				
			||||||
 | 
					        """Returns the debit line item errors, without the errors in their
 | 
				
			||||||
 | 
					        sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [x for x in self.debit.errors
 | 
				
			||||||
 | 
					                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TransferCurrencyForm(CurrencyForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a currency in a transfer journal entry."""
 | 
				
			||||||
 | 
					    no = IntegerField()
 | 
				
			||||||
 | 
					    """The order in the journal entry."""
 | 
				
			||||||
 | 
					    code = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[CURRENCY_REQUIRED,
 | 
				
			||||||
 | 
					                    CurrencyExists(),
 | 
				
			||||||
 | 
					                    SameCurrencyAsOriginalLineItems(),
 | 
				
			||||||
 | 
					                    KeepCurrencyWhenHavingOffset()])
 | 
				
			||||||
 | 
					    """The currency code."""
 | 
				
			||||||
 | 
					    debit = FieldList(FormField(DebitLineItemForm),
 | 
				
			||||||
 | 
					                      validators=[NeedSomeLineItems()])
 | 
				
			||||||
 | 
					    """The debit line items."""
 | 
				
			||||||
 | 
					    credit = FieldList(FormField(CreditLineItemForm),
 | 
				
			||||||
 | 
					                       validators=[NeedSomeLineItems()])
 | 
				
			||||||
 | 
					    """The credit line items."""
 | 
				
			||||||
 | 
					    whole_form = BooleanField(validators=[IsBalanced()])
 | 
				
			||||||
 | 
					    """The pseudo field for the whole form validators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def debit_total(self) -> Decimal:
 | 
				
			||||||
 | 
					        """Returns the total amount of the debit line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The total amount of the debit line items.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return sum([x.amount.data for x in self.debit
 | 
				
			||||||
 | 
					                    if x.amount.data is not None])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def credit_total(self) -> Decimal:
 | 
				
			||||||
 | 
					        """Returns the total amount of the credit line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The total amount of the credit line items.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return sum([x.amount.data for x in self.credit
 | 
				
			||||||
 | 
					                    if x.amount.data is not None])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def debit_errors(self) -> list[str | LazyString]:
 | 
				
			||||||
 | 
					        """Returns the debit line item errors, without the errors in their
 | 
				
			||||||
 | 
					        sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [x for x in self.debit.errors
 | 
				
			||||||
 | 
					                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def credit_errors(self) -> list[str | LazyString]:
 | 
				
			||||||
 | 
					        """Returns the credit line item errors, without the errors in their
 | 
				
			||||||
 | 
					        sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [x for x in self.credit.errors
 | 
				
			||||||
 | 
					                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
				
			||||||
							
								
								
									
										593
									
								
								src/accounting/journal_entry/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										593
									
								
								src/accounting/journal_entry/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,593 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The journal entry forms for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import datetime as dt
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					from abc import ABC, abstractmethod
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask_babel import LazyString
 | 
				
			||||||
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
 | 
					from wtforms import DateField, FieldList, FormField, TextAreaField, \
 | 
				
			||||||
 | 
					    BooleanField
 | 
				
			||||||
 | 
					from wtforms.validators import DataRequired, ValidationError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
 | 
				
			||||||
 | 
					    JournalEntryCurrency
 | 
				
			||||||
 | 
					from accounting.journal_entry.utils.account_option import AccountOption
 | 
				
			||||||
 | 
					from accounting.journal_entry.utils.original_line_items import \
 | 
				
			||||||
 | 
					    get_selectable_original_line_items
 | 
				
			||||||
 | 
					from accounting.journal_entry.utils.description_editor import DescriptionEditor
 | 
				
			||||||
 | 
					from accounting.utils.random_id import new_id
 | 
				
			||||||
 | 
					from accounting.utils.strip_text import strip_multiline_text
 | 
				
			||||||
 | 
					from accounting.utils.user import get_current_user_pk
 | 
				
			||||||
 | 
					from .currency import CurrencyForm, CashReceiptCurrencyForm, \
 | 
				
			||||||
 | 
					    CashDisbursementCurrencyForm, TransferCurrencyForm
 | 
				
			||||||
 | 
					from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
 | 
				
			||||||
 | 
					from .reorder import sort_journal_entries_in
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DATE_REQUIRED: DataRequired = DataRequired(
 | 
				
			||||||
 | 
					    lazy_gettext("Please fill in the date."))
 | 
				
			||||||
 | 
					"""The validator to check if the date is empty."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotBeforeOriginalLineItems:
 | 
				
			||||||
 | 
					    """The validator to check if the date is not before the
 | 
				
			||||||
 | 
					    original line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: DateField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, JournalEntryForm)
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        min_date: dt.date | None = form.min_date
 | 
				
			||||||
 | 
					        if min_date is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if field.data < min_date:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The date cannot be earlier than the original line items."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotAfterOffsetItems:
 | 
				
			||||||
 | 
					    """The validator to check if the date is not after the offset items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: DateField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, JournalEntryForm)
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        max_date: dt.date | None = form.max_date
 | 
				
			||||||
 | 
					        if max_date is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if field.data > max_date:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The date cannot be later than the offset items."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NeedSomeCurrencies:
 | 
				
			||||||
 | 
					    """The validator to check if there is any currency sub-form."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: FieldList) -> None:
 | 
				
			||||||
 | 
					        if len(field) == 0:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext("Please add some currencies."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CannotDeleteOriginalLineItemsWithOffset:
 | 
				
			||||||
 | 
					    """The validator to check the original line items with offset."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: FieldList) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, JournalEntryForm)
 | 
				
			||||||
 | 
					        if form.obj is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        existing_matched_original_line_item_id: set[int] \
 | 
				
			||||||
 | 
					            = {x.id for x in form.obj.line_items if len(x.offsets) > 0}
 | 
				
			||||||
 | 
					        line_item_id_in_form: set[int] \
 | 
				
			||||||
 | 
					            = {x.id.data for x in form.line_items if x.id.data is not None}
 | 
				
			||||||
 | 
					        for line_item_id in existing_matched_original_line_item_id:
 | 
				
			||||||
 | 
					            if line_item_id not in line_item_id_in_form:
 | 
				
			||||||
 | 
					                raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                    "Line items with offset cannot be deleted."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class JournalEntryForm(FlaskForm):
 | 
				
			||||||
 | 
					    """The base form to create or edit a journal entry."""
 | 
				
			||||||
 | 
					    date = DateField()
 | 
				
			||||||
 | 
					    """The date."""
 | 
				
			||||||
 | 
					    currencies = FieldList(FormField(CurrencyForm))
 | 
				
			||||||
 | 
					    """The line items categorized by their currencies."""
 | 
				
			||||||
 | 
					    note = TextAreaField()
 | 
				
			||||||
 | 
					    """The note."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        """Constructs a base journal entry form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param args: The arguments.
 | 
				
			||||||
 | 
					        :param kwargs: The keyword arguments.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self.is_modified: bool = False
 | 
				
			||||||
 | 
					        """Whether the journal entry is modified during populate_obj()."""
 | 
				
			||||||
 | 
					        self.collector: t.Type[LineItemCollector] = LineItemCollector
 | 
				
			||||||
 | 
					        """The line item collector.  The default is the base abstract
 | 
				
			||||||
 | 
					        collector only to provide the correct type.  The subclass forms should
 | 
				
			||||||
 | 
					        provide their own collectors."""
 | 
				
			||||||
 | 
					        self.obj: JournalEntry | None = kwargs.get("obj")
 | 
				
			||||||
 | 
					        """The journal entry, when editing an existing one."""
 | 
				
			||||||
 | 
					        self._is_need_payable: bool = False
 | 
				
			||||||
 | 
					        """Whether we need the payable original line items."""
 | 
				
			||||||
 | 
					        self._is_need_receivable: bool = False
 | 
				
			||||||
 | 
					        """Whether we need the receivable original line items."""
 | 
				
			||||||
 | 
					        self.__original_line_item_options: list[JournalEntryLineItem] | None \
 | 
				
			||||||
 | 
					            = None
 | 
				
			||||||
 | 
					        """The options of the original line items."""
 | 
				
			||||||
 | 
					        self.__net_balance_exceeded: dict[int, LazyString] | None = None
 | 
				
			||||||
 | 
					        """The original line items whose net balances were exceeded by the
 | 
				
			||||||
 | 
					        amounts in the line item sub-forms."""
 | 
				
			||||||
 | 
					        for line_item in self.line_items:
 | 
				
			||||||
 | 
					            line_item.journal_entry_form = self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_obj(self, obj: JournalEntry) -> None:
 | 
				
			||||||
 | 
					        """Populates the form data into a journal entry object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param obj: The journal entry object.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        is_new: bool = obj.id is None
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            obj.id = new_id(JournalEntry)
 | 
				
			||||||
 | 
					        self.date: DateField
 | 
				
			||||||
 | 
					        self.__set_date(obj, self.date.data)
 | 
				
			||||||
 | 
					        obj.note = self.note.data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        collector_cls: t.Type[LineItemCollector] = self.collector
 | 
				
			||||||
 | 
					        collector: collector_cls = collector_cls(self, obj)
 | 
				
			||||||
 | 
					        collector.collect()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        to_delete: set[int] = {x.id for x in obj.line_items
 | 
				
			||||||
 | 
					                               if x.id not in collector.to_keep}
 | 
				
			||||||
 | 
					        if len(to_delete) > 0:
 | 
				
			||||||
 | 
					            JournalEntryLineItem.query\
 | 
				
			||||||
 | 
					                .filter(JournalEntryLineItem.id.in_(to_delete)).delete()
 | 
				
			||||||
 | 
					            self.is_modified = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if is_new or db.session.is_modified(obj):
 | 
				
			||||||
 | 
					            self.is_modified = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
 | 
					            obj.created_by_id = current_user_pk
 | 
				
			||||||
 | 
					            obj.updated_by_id = current_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def line_items(self) -> list[LineItemForm]:
 | 
				
			||||||
 | 
					        """Collects and returns the line item sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The line item sub-forms.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        line_items: list[LineItemForm] = []
 | 
				
			||||||
 | 
					        for currency in self.currencies:
 | 
				
			||||||
 | 
					            line_items.extend(currency.line_items)
 | 
				
			||||||
 | 
					        return line_items
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None:
 | 
				
			||||||
 | 
					        """Sets the journal entry date and number.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param obj: The journal entry object.
 | 
				
			||||||
 | 
					        :param new_date: The new date.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if obj.date is None or obj.date != new_date:
 | 
				
			||||||
 | 
					            if obj.date is not None:
 | 
				
			||||||
 | 
					                sort_journal_entries_in(obj.date, obj.id)
 | 
				
			||||||
 | 
					            if self.max_date is not None and new_date == self.max_date:
 | 
				
			||||||
 | 
					                db_min_no: int | None = db.session.scalar(
 | 
				
			||||||
 | 
					                    sa.select(sa.func.min(JournalEntry.no))
 | 
				
			||||||
 | 
					                    .filter(JournalEntry.date == new_date))
 | 
				
			||||||
 | 
					                if db_min_no is None:
 | 
				
			||||||
 | 
					                    obj.date = new_date
 | 
				
			||||||
 | 
					                    obj.no = 1
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    obj.date = new_date
 | 
				
			||||||
 | 
					                    obj.no = db_min_no - 1
 | 
				
			||||||
 | 
					                    sort_journal_entries_in(new_date)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                sort_journal_entries_in(new_date, obj.id)
 | 
				
			||||||
 | 
					                count: int = JournalEntry.query\
 | 
				
			||||||
 | 
					                    .filter(JournalEntry.date == new_date).count()
 | 
				
			||||||
 | 
					                obj.date = new_date
 | 
				
			||||||
 | 
					                obj.no = count + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def debit_account_options(self) -> list[AccountOption]:
 | 
				
			||||||
 | 
					        """The selectable debit accounts.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The selectable debit accounts.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        accounts: list[AccountOption] \
 | 
				
			||||||
 | 
					            = [AccountOption(x) for x in Account.debit()
 | 
				
			||||||
 | 
					               if not (x.code[0] == "2" and x.is_need_offset)]
 | 
				
			||||||
 | 
					        in_use: set[int] = set(db.session.scalars(
 | 
				
			||||||
 | 
					            sa.select(JournalEntryLineItem.account_id)
 | 
				
			||||||
 | 
					            .filter(JournalEntryLineItem.is_debit)
 | 
				
			||||||
 | 
					            .group_by(JournalEntryLineItem.account_id)).all())
 | 
				
			||||||
 | 
					        for account in accounts:
 | 
				
			||||||
 | 
					            account.is_in_use = account.id in in_use
 | 
				
			||||||
 | 
					        return accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def credit_account_options(self) -> list[AccountOption]:
 | 
				
			||||||
 | 
					        """The selectable credit accounts.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The selectable credit accounts.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        accounts: list[AccountOption] \
 | 
				
			||||||
 | 
					            = [AccountOption(x) for x in Account.credit()
 | 
				
			||||||
 | 
					               if not (x.code[0] == "1" and x.is_need_offset)]
 | 
				
			||||||
 | 
					        in_use: set[int] = set(db.session.scalars(
 | 
				
			||||||
 | 
					            sa.select(JournalEntryLineItem.account_id)
 | 
				
			||||||
 | 
					            .filter(sa.not_(JournalEntryLineItem.is_debit))
 | 
				
			||||||
 | 
					            .group_by(JournalEntryLineItem.account_id)).all())
 | 
				
			||||||
 | 
					        for account in accounts:
 | 
				
			||||||
 | 
					            account.is_in_use = account.id in in_use
 | 
				
			||||||
 | 
					        return accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def currencies_errors(self) -> list[str | LazyString]:
 | 
				
			||||||
 | 
					        """Returns the currency errors, without the errors in their sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The currency errors, without the errors in their sub-forms.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [x for x in self.currencies.errors
 | 
				
			||||||
 | 
					                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def description_editor(self) -> DescriptionEditor:
 | 
				
			||||||
 | 
					        """Returns the description editor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The description editor.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return DescriptionEditor()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def original_line_item_options(self) -> list[JournalEntryLineItem]:
 | 
				
			||||||
 | 
					        """Returns the selectable original line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The selectable original line items.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if self.__original_line_item_options is None:
 | 
				
			||||||
 | 
					            self.__original_line_item_options \
 | 
				
			||||||
 | 
					                = get_selectable_original_line_items(
 | 
				
			||||||
 | 
					                    {x.id.data for x in self.line_items
 | 
				
			||||||
 | 
					                     if x.id.data is not None},
 | 
				
			||||||
 | 
					                    self._is_need_payable, self._is_need_receivable)
 | 
				
			||||||
 | 
					        return self.__original_line_item_options
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def min_date(self) -> dt.date | None:
 | 
				
			||||||
 | 
					        """Returns the minimal available date.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The minimal available date.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        original_line_item_id: set[int] \
 | 
				
			||||||
 | 
					            = {x.original_line_item_id.data for x in self.line_items
 | 
				
			||||||
 | 
					               if x.original_line_item_id.data is not None}
 | 
				
			||||||
 | 
					        if len(original_line_item_id) == 0:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
 | 
				
			||||||
 | 
					            .join(JournalEntryLineItem)\
 | 
				
			||||||
 | 
					            .filter(JournalEntryLineItem.id.in_(original_line_item_id))
 | 
				
			||||||
 | 
					        return db.session.scalar(select)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def max_date(self) -> dt.date | None:
 | 
				
			||||||
 | 
					        """Returns the maximum available date.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The maximum available date.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        line_item_id: set[int] = {x.id.data for x in self.line_items
 | 
				
			||||||
 | 
					                                  if x.id.data is not None}
 | 
				
			||||||
 | 
					        select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
 | 
				
			||||||
 | 
					            .join(JournalEntryLineItem)\
 | 
				
			||||||
 | 
					            .filter(JournalEntryLineItem.original_line_item_id
 | 
				
			||||||
 | 
					                    .in_(line_item_id))
 | 
				
			||||||
 | 
					        return db.session.scalar(select)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					T = t.TypeVar("T", bound=JournalEntryForm)
 | 
				
			||||||
 | 
					"""A journal entry form variant."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LineItemCollector(t.Generic[T], ABC):
 | 
				
			||||||
 | 
					    """The line item collector."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, form: T, obj: JournalEntry):
 | 
				
			||||||
 | 
					        """Constructs the line item collector.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param form: The journal entry form.
 | 
				
			||||||
 | 
					        :param obj: The journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.form: T = form
 | 
				
			||||||
 | 
					        """The journal entry form."""
 | 
				
			||||||
 | 
					        self.__obj: JournalEntry = obj
 | 
				
			||||||
 | 
					        """The journal entry object."""
 | 
				
			||||||
 | 
					        self.__line_items: list[JournalEntryLineItem] = list(obj.line_items)
 | 
				
			||||||
 | 
					        """The existing line items."""
 | 
				
			||||||
 | 
					        self.__line_items_by_id: dict[int, JournalEntryLineItem] \
 | 
				
			||||||
 | 
					            = {x.id: x for x in self.__line_items}
 | 
				
			||||||
 | 
					        """A dictionary from the line item ID to their line items."""
 | 
				
			||||||
 | 
					        self.__no_by_id: dict[int, int] \
 | 
				
			||||||
 | 
					            = {x.id: x.no for x in self.__line_items}
 | 
				
			||||||
 | 
					        """A dictionary from the line item number to their line items."""
 | 
				
			||||||
 | 
					        self.__currencies: list[JournalEntryCurrency] = obj.currencies
 | 
				
			||||||
 | 
					        """The currencies in the journal entry."""
 | 
				
			||||||
 | 
					        self._debit_no: int = 1
 | 
				
			||||||
 | 
					        """The number index for the debit line items."""
 | 
				
			||||||
 | 
					        self._credit_no: int = 1
 | 
				
			||||||
 | 
					        """The number index for the credit line items."""
 | 
				
			||||||
 | 
					        self.to_keep: set[int] = set()
 | 
				
			||||||
 | 
					        """The ID of the existing line items to keep."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def collect(self) -> set[int]:
 | 
				
			||||||
 | 
					        """Collects the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The ID of the line items to keep.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
 | 
				
			||||||
 | 
					            -> None:
 | 
				
			||||||
 | 
					        """Composes a line item from the form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param form: The line item form.
 | 
				
			||||||
 | 
					        :param currency_code: The code of the currency.
 | 
				
			||||||
 | 
					        :param no: The number of the line item.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        line_item: JournalEntryLineItem | None \
 | 
				
			||||||
 | 
					            = self.__line_items_by_id.get(form.id.data)
 | 
				
			||||||
 | 
					        if line_item is not None:
 | 
				
			||||||
 | 
					            line_item.currency_code = currency_code
 | 
				
			||||||
 | 
					            form.populate_obj(line_item)
 | 
				
			||||||
 | 
					            line_item.no = no
 | 
				
			||||||
 | 
					            if db.session.is_modified(line_item):
 | 
				
			||||||
 | 
					                self.form.is_modified = True
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            line_item = JournalEntryLineItem()
 | 
				
			||||||
 | 
					            line_item.currency_code = currency_code
 | 
				
			||||||
 | 
					            form.populate_obj(line_item)
 | 
				
			||||||
 | 
					            line_item.no = no
 | 
				
			||||||
 | 
					            self.__obj.line_items.append(line_item)
 | 
				
			||||||
 | 
					            self.form.is_modified = True
 | 
				
			||||||
 | 
					        self.to_keep.add(line_item.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
 | 
				
			||||||
 | 
					                             currency_code: str, no: int) -> None:
 | 
				
			||||||
 | 
					        """Composes the cash line item at the other debit or credit of the
 | 
				
			||||||
 | 
					        cash journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param forms: The line item forms in the same currency.
 | 
				
			||||||
 | 
					        :param is_debit: True for a cash receipt journal entry, or False for a
 | 
				
			||||||
 | 
					            cash disbursement journal entry.
 | 
				
			||||||
 | 
					        :param currency_code: The code of the currency.
 | 
				
			||||||
 | 
					        :param no: The number of the line item.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        candidates: list[JournalEntryLineItem] \
 | 
				
			||||||
 | 
					            = [x for x in self.__line_items
 | 
				
			||||||
 | 
					               if x.is_debit == is_debit and x.currency_code == currency_code]
 | 
				
			||||||
 | 
					        line_item: JournalEntryLineItem
 | 
				
			||||||
 | 
					        if len(candidates) > 0:
 | 
				
			||||||
 | 
					            candidates.sort(key=lambda x: x.no)
 | 
				
			||||||
 | 
					            line_item = candidates[0]
 | 
				
			||||||
 | 
					            line_item.account_id = Account.cash().id
 | 
				
			||||||
 | 
					            line_item.description = None
 | 
				
			||||||
 | 
					            line_item.amount = sum([x.amount.data for x in forms])
 | 
				
			||||||
 | 
					            line_item.no = no
 | 
				
			||||||
 | 
					            if db.session.is_modified(line_item):
 | 
				
			||||||
 | 
					                self.form.is_modified = True
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            line_item = JournalEntryLineItem()
 | 
				
			||||||
 | 
					            line_item.id = new_id(JournalEntryLineItem)
 | 
				
			||||||
 | 
					            line_item.is_debit = is_debit
 | 
				
			||||||
 | 
					            line_item.currency_code = currency_code
 | 
				
			||||||
 | 
					            line_item.account_id = Account.cash().id
 | 
				
			||||||
 | 
					            line_item.description = None
 | 
				
			||||||
 | 
					            line_item.amount = sum([x.amount.data for x in forms])
 | 
				
			||||||
 | 
					            line_item.no = no
 | 
				
			||||||
 | 
					            self.__obj.line_items.append(line_item)
 | 
				
			||||||
 | 
					            self.form.is_modified = True
 | 
				
			||||||
 | 
					        self.to_keep.add(line_item.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
 | 
				
			||||||
 | 
					        """Sorts the line item sub-forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param forms: The line item sub-forms.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        missing_no: int = 100 if len(self.__no_by_id) == 0 \
 | 
				
			||||||
 | 
					            else max(self.__no_by_id.values()) + 100
 | 
				
			||||||
 | 
					        ord_by_form: dict[LineItemForm, int] \
 | 
				
			||||||
 | 
					            = {forms[i]: i for i in range(len(forms))}
 | 
				
			||||||
 | 
					        recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
 | 
				
			||||||
 | 
					        missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
 | 
				
			||||||
 | 
					        forms.sort(key=lambda x: (x.no.data or missing_recv_no,
 | 
				
			||||||
 | 
					                                  missing_no if x.id.data is None else
 | 
				
			||||||
 | 
					                                  self.__no_by_id.get(x.id.data, missing_no),
 | 
				
			||||||
 | 
					                                  ord_by_form.get(x)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
 | 
				
			||||||
 | 
					        """Sorts the currency forms.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param forms: The currency forms.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        missing_no: int = len(self.__currencies) + 100
 | 
				
			||||||
 | 
					        no_by_code: dict[str, int] = {self.__currencies[i].code: i
 | 
				
			||||||
 | 
					                                      for i in range(len(self.__currencies))}
 | 
				
			||||||
 | 
					        ord_by_form: dict[CurrencyForm, int] \
 | 
				
			||||||
 | 
					            = {forms[i]: i for i in range(len(forms))}
 | 
				
			||||||
 | 
					        recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
 | 
				
			||||||
 | 
					        missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
 | 
				
			||||||
 | 
					        forms.sort(key=lambda x: (x.no.data or missing_recv_no,
 | 
				
			||||||
 | 
					                                  no_by_code.get(x.code.data, missing_no),
 | 
				
			||||||
 | 
					                                  ord_by_form.get(x)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CashReceiptJournalEntryForm(JournalEntryForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a cash receipt journal entry."""
 | 
				
			||||||
 | 
					    date = DateField(
 | 
				
			||||||
 | 
					        validators=[DATE_REQUIRED,
 | 
				
			||||||
 | 
					                    NotBeforeOriginalLineItems(),
 | 
				
			||||||
 | 
					                    NotAfterOffsetItems()])
 | 
				
			||||||
 | 
					    """The date."""
 | 
				
			||||||
 | 
					    currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
 | 
				
			||||||
 | 
					                           validators=[NeedSomeCurrencies()])
 | 
				
			||||||
 | 
					    """The line items categorized by their currencies."""
 | 
				
			||||||
 | 
					    note = TextAreaField(filters=[strip_multiline_text])
 | 
				
			||||||
 | 
					    """The note."""
 | 
				
			||||||
 | 
					    whole_form = BooleanField(
 | 
				
			||||||
 | 
					        validators=[CannotDeleteOriginalLineItemsWithOffset()])
 | 
				
			||||||
 | 
					    """The pseudo field for the whole form validators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self._is_need_receivable = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        class Collector(LineItemCollector[CashReceiptJournalEntryForm]):
 | 
				
			||||||
 | 
					            """The line item collector for the cash receipt journal entries."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            def collect(self) -> None:
 | 
				
			||||||
 | 
					                currencies: list[CashReceiptCurrencyForm] \
 | 
				
			||||||
 | 
					                    = [x.form for x in self.form.currencies]
 | 
				
			||||||
 | 
					                self._sort_currency_forms(currencies)
 | 
				
			||||||
 | 
					                for currency in currencies:
 | 
				
			||||||
 | 
					                    # The debit cash line item
 | 
				
			||||||
 | 
					                    self._make_cash_line_item(list(currency.credit), True,
 | 
				
			||||||
 | 
					                                              currency.code.data,
 | 
				
			||||||
 | 
					                                              self._debit_no)
 | 
				
			||||||
 | 
					                    self._debit_no = self._debit_no + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    # The credit forms
 | 
				
			||||||
 | 
					                    credit_forms: list[CreditLineItemForm] \
 | 
				
			||||||
 | 
					                        = [x.form for x in currency.credit]
 | 
				
			||||||
 | 
					                    self._sort_line_item_forms(credit_forms)
 | 
				
			||||||
 | 
					                    for credit_form in credit_forms:
 | 
				
			||||||
 | 
					                        self._add_line_item(credit_form, currency.code.data,
 | 
				
			||||||
 | 
					                                            self._credit_no)
 | 
				
			||||||
 | 
					                        self._credit_no = self._credit_no + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.collector = Collector
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CashDisbursementJournalEntryForm(JournalEntryForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a cash disbursement journal entry."""
 | 
				
			||||||
 | 
					    date = DateField(
 | 
				
			||||||
 | 
					        validators=[DATE_REQUIRED,
 | 
				
			||||||
 | 
					                    NotBeforeOriginalLineItems(),
 | 
				
			||||||
 | 
					                    NotAfterOffsetItems()])
 | 
				
			||||||
 | 
					    """The date."""
 | 
				
			||||||
 | 
					    currencies = FieldList(FormField(CashDisbursementCurrencyForm),
 | 
				
			||||||
 | 
					                           name="currency",
 | 
				
			||||||
 | 
					                           validators=[NeedSomeCurrencies()])
 | 
				
			||||||
 | 
					    """The line items categorized by their currencies."""
 | 
				
			||||||
 | 
					    note = TextAreaField(filters=[strip_multiline_text])
 | 
				
			||||||
 | 
					    """The note."""
 | 
				
			||||||
 | 
					    whole_form = BooleanField(
 | 
				
			||||||
 | 
					        validators=[CannotDeleteOriginalLineItemsWithOffset()])
 | 
				
			||||||
 | 
					    """The pseudo field for the whole form validators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self._is_need_payable = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        class Collector(LineItemCollector[CashDisbursementJournalEntryForm]):
 | 
				
			||||||
 | 
					            """The line item collector for the cash disbursement journal
 | 
				
			||||||
 | 
					            entries."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            def collect(self) -> None:
 | 
				
			||||||
 | 
					                currencies: list[CashDisbursementCurrencyForm] \
 | 
				
			||||||
 | 
					                    = [x.form for x in self.form.currencies]
 | 
				
			||||||
 | 
					                self._sort_currency_forms(currencies)
 | 
				
			||||||
 | 
					                for currency in currencies:
 | 
				
			||||||
 | 
					                    # The debit forms
 | 
				
			||||||
 | 
					                    debit_forms: list[DebitLineItemForm] \
 | 
				
			||||||
 | 
					                        = [x.form for x in currency.debit]
 | 
				
			||||||
 | 
					                    self._sort_line_item_forms(debit_forms)
 | 
				
			||||||
 | 
					                    for debit_form in debit_forms:
 | 
				
			||||||
 | 
					                        self._add_line_item(debit_form, currency.code.data,
 | 
				
			||||||
 | 
					                                            self._debit_no)
 | 
				
			||||||
 | 
					                        self._debit_no = self._debit_no + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    # The credit forms
 | 
				
			||||||
 | 
					                    self._make_cash_line_item(list(currency.debit), False,
 | 
				
			||||||
 | 
					                                              currency.code.data,
 | 
				
			||||||
 | 
					                                              self._credit_no)
 | 
				
			||||||
 | 
					                    self._credit_no = self._credit_no + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.collector = Collector
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TransferJournalEntryForm(JournalEntryForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a transfer journal entry."""
 | 
				
			||||||
 | 
					    date = DateField(
 | 
				
			||||||
 | 
					        validators=[DATE_REQUIRED,
 | 
				
			||||||
 | 
					                    NotBeforeOriginalLineItems(),
 | 
				
			||||||
 | 
					                    NotAfterOffsetItems()])
 | 
				
			||||||
 | 
					    """The date."""
 | 
				
			||||||
 | 
					    currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
 | 
				
			||||||
 | 
					                           validators=[NeedSomeCurrencies()])
 | 
				
			||||||
 | 
					    """The line items categorized by their currencies."""
 | 
				
			||||||
 | 
					    note = TextAreaField(filters=[strip_multiline_text])
 | 
				
			||||||
 | 
					    """The note."""
 | 
				
			||||||
 | 
					    whole_form = BooleanField(
 | 
				
			||||||
 | 
					        validators=[CannotDeleteOriginalLineItemsWithOffset()])
 | 
				
			||||||
 | 
					    """The pseudo field for the whole form validators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self._is_need_payable = True
 | 
				
			||||||
 | 
					        self._is_need_receivable = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        class Collector(LineItemCollector[TransferJournalEntryForm]):
 | 
				
			||||||
 | 
					            """The line item collector for the transfer journal entries."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            def collect(self) -> None:
 | 
				
			||||||
 | 
					                currencies: list[TransferCurrencyForm] \
 | 
				
			||||||
 | 
					                    = [x.form for x in self.form.currencies]
 | 
				
			||||||
 | 
					                self._sort_currency_forms(currencies)
 | 
				
			||||||
 | 
					                for currency in currencies:
 | 
				
			||||||
 | 
					                    # The debit forms
 | 
				
			||||||
 | 
					                    debit_forms: list[DebitLineItemForm] \
 | 
				
			||||||
 | 
					                        = [x.form for x in currency.debit]
 | 
				
			||||||
 | 
					                    self._sort_line_item_forms(debit_forms)
 | 
				
			||||||
 | 
					                    for debit_form in debit_forms:
 | 
				
			||||||
 | 
					                        self._add_line_item(debit_form, currency.code.data,
 | 
				
			||||||
 | 
					                                            self._debit_no)
 | 
				
			||||||
 | 
					                        self._debit_no = self._debit_no + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    # The credit forms
 | 
				
			||||||
 | 
					                    credit_forms: list[CreditLineItemForm] \
 | 
				
			||||||
 | 
					                        = [x.form for x in currency.credit]
 | 
				
			||||||
 | 
					                    self._sort_line_item_forms(credit_forms)
 | 
				
			||||||
 | 
					                    for credit_form in credit_forms:
 | 
				
			||||||
 | 
					                        self._add_line_item(credit_form, currency.code.data,
 | 
				
			||||||
 | 
					                                            self._credit_no)
 | 
				
			||||||
 | 
					                        self._credit_no = self._credit_no + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.collector = Collector
 | 
				
			||||||
							
								
								
									
										537
									
								
								src/accounting/journal_entry/forms/line_item.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										537
									
								
								src/accounting/journal_entry/forms/line_item.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,537 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The line item sub-forms for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					from datetime import date
 | 
				
			||||||
 | 
					from decimal import Decimal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask_babel import LazyString
 | 
				
			||||||
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
 | 
					from sqlalchemy.orm import selectinload
 | 
				
			||||||
 | 
					from wtforms import StringField, ValidationError, DecimalField, IntegerField
 | 
				
			||||||
 | 
					from wtforms.validators import DataRequired, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import Account, JournalEntryLineItem
 | 
				
			||||||
 | 
					from accounting.template_filters import format_amount
 | 
				
			||||||
 | 
					from accounting.utils.cast import be
 | 
				
			||||||
 | 
					from accounting.utils.random_id import new_id
 | 
				
			||||||
 | 
					from accounting.utils.strip_text import strip_text
 | 
				
			||||||
 | 
					from accounting.utils.user import get_current_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ACCOUNT_REQUIRED: DataRequired = DataRequired(
 | 
				
			||||||
 | 
					    lazy_gettext("Please select the account."))
 | 
				
			||||||
 | 
					"""The validator to check if the account code is empty."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OriginalLineItemExists:
 | 
				
			||||||
 | 
					    """The validator to check if the original line item exists."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if db.session.get(JournalEntryLineItem, field.data) is None:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The original line item does not exist."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OriginalLineItemOppositeDebitCredit:
 | 
				
			||||||
 | 
					    """The validator to check if the original line item is on the opposite
 | 
				
			||||||
 | 
					    debit or credit."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        original_line_item: JournalEntryLineItem | None \
 | 
				
			||||||
 | 
					            = db.session.get(JournalEntryLineItem, field.data)
 | 
				
			||||||
 | 
					        if original_line_item is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if isinstance(form, CreditLineItemForm) \
 | 
				
			||||||
 | 
					                and original_line_item.is_debit:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if isinstance(form, DebitLineItemForm) \
 | 
				
			||||||
 | 
					                and not original_line_item.is_debit:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					            "The original line item is on the same debit or credit."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OriginalLineItemNeedOffset:
 | 
				
			||||||
 | 
					    """The validator to check if the original line item needs offset."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        original_line_item: JournalEntryLineItem | None \
 | 
				
			||||||
 | 
					            = db.session.get(JournalEntryLineItem, field.data)
 | 
				
			||||||
 | 
					        if original_line_item is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if not original_line_item.account.is_need_offset:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The original line item does not need offset."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OriginalLineItemNotOffset:
 | 
				
			||||||
 | 
					    """The validator to check if the original line item is not itself an
 | 
				
			||||||
 | 
					    offset item."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        original_line_item: JournalEntryLineItem | None \
 | 
				
			||||||
 | 
					            = db.session.get(JournalEntryLineItem, field.data)
 | 
				
			||||||
 | 
					        if original_line_item is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if original_line_item.original_line_item_id is not None:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The original line item cannot be an offset item."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountExists:
 | 
				
			||||||
 | 
					    """The validator to check if the account exists."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if Account.find_by_code(field.data) is None:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The account does not exist."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class IsDebitAccount:
 | 
				
			||||||
 | 
					    """The validator to check if the account is for debit line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if re.match(r"^(?:[1235689]|7[5678])", field.data) \
 | 
				
			||||||
 | 
					                and not field.data.startswith("3351-") \
 | 
				
			||||||
 | 
					                and not field.data.startswith("3353-"):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					            "This account is not for debit line items."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class IsCreditAccount:
 | 
				
			||||||
 | 
					    """The validator to check if the account is for credit line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if re.match(r"^(?:[123489]|7[1234])", field.data) \
 | 
				
			||||||
 | 
					                and not field.data.startswith("3351-") \
 | 
				
			||||||
 | 
					                and not field.data.startswith("3353-"):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					            "This account is not for credit line items."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SameAccountAsOriginalLineItem:
 | 
				
			||||||
 | 
					    """The validator to check if the account is the same as the
 | 
				
			||||||
 | 
					    original line item."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, LineItemForm)
 | 
				
			||||||
 | 
					        if field.data is None or form.original_line_item_id.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        original_line_item: JournalEntryLineItem | None \
 | 
				
			||||||
 | 
					            = db.session.get(JournalEntryLineItem,
 | 
				
			||||||
 | 
					                             form.original_line_item_id.data)
 | 
				
			||||||
 | 
					        if original_line_item is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if field.data != original_line_item.account_code:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The account must be the same as the original line item."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class KeepAccountWhenHavingOffset:
 | 
				
			||||||
 | 
					    """The validator to check if the account is the same when having offset."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, LineItemForm)
 | 
				
			||||||
 | 
					        if field.data is None or form.id.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        line_item: JournalEntryLineItem | None = db.session\
 | 
				
			||||||
 | 
					            .query(JournalEntryLineItem)\
 | 
				
			||||||
 | 
					            .filter(JournalEntryLineItem.id == form.id.data)\
 | 
				
			||||||
 | 
					            .options(selectinload(JournalEntryLineItem.offsets)).first()
 | 
				
			||||||
 | 
					        if line_item is None or len(line_item.offsets) == 0:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if field.data != line_item.account_code:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The account must not be changed when there is offset."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotStartPayableFromDebit:
 | 
				
			||||||
 | 
					    """The validator to check that a payable line item does not start from
 | 
				
			||||||
 | 
					    debit."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, DebitLineItemForm)
 | 
				
			||||||
 | 
					        if field.data is None \
 | 
				
			||||||
 | 
					                or field.data[0] != "2" \
 | 
				
			||||||
 | 
					                or form.original_line_item_id.data is not None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        account: Account | None = Account.find_by_code(field.data)
 | 
				
			||||||
 | 
					        if account is not None and account.is_need_offset:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "A payable line item cannot start from debit."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotStartReceivableFromCredit:
 | 
				
			||||||
 | 
					    """The validator to check that a receivable line item does not start
 | 
				
			||||||
 | 
					    from credit."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, CreditLineItemForm)
 | 
				
			||||||
 | 
					        if field.data is None \
 | 
				
			||||||
 | 
					                or field.data[0] != "1" \
 | 
				
			||||||
 | 
					                or form.original_line_item_id.data is not None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        account: Account | None = Account.find_by_code(field.data)
 | 
				
			||||||
 | 
					        if account is not None and account.is_need_offset:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "A receivable line item cannot start from credit."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PositiveAmount:
 | 
				
			||||||
 | 
					    """The validator to check if the amount is positive."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
 | 
				
			||||||
 | 
					        if field.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if field.data <= 0:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "Please fill in a positive amount."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotExceedingOriginalLineItemNetBalance:
 | 
				
			||||||
 | 
					    """The validator to check if the amount exceeds the net balance of the
 | 
				
			||||||
 | 
					    original line item."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, LineItemForm)
 | 
				
			||||||
 | 
					        if field.data is None or form.original_line_item_id.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        original_line_item: JournalEntryLineItem | None \
 | 
				
			||||||
 | 
					            = db.session.get(JournalEntryLineItem,
 | 
				
			||||||
 | 
					                             form.original_line_item_id.data)
 | 
				
			||||||
 | 
					        if original_line_item is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        is_debit: bool = isinstance(form, DebitLineItemForm)
 | 
				
			||||||
 | 
					        existing_line_item_id: set[int] = set()
 | 
				
			||||||
 | 
					        if form.journal_entry_form.obj is not None:
 | 
				
			||||||
 | 
					            existing_line_item_id \
 | 
				
			||||||
 | 
					                = {x.id for x in form.journal_entry_form.obj.line_items}
 | 
				
			||||||
 | 
					        offset_total_func: sa.Function = sa.func.sum(sa.case(
 | 
				
			||||||
 | 
					            (be(JournalEntryLineItem.is_debit == is_debit),
 | 
				
			||||||
 | 
					             JournalEntryLineItem.amount),
 | 
				
			||||||
 | 
					            else_=-JournalEntryLineItem.amount))
 | 
				
			||||||
 | 
					        offset_total_but_form: Decimal | None = db.session.scalar(
 | 
				
			||||||
 | 
					            sa.select(offset_total_func)
 | 
				
			||||||
 | 
					            .filter(be(JournalEntryLineItem.original_line_item_id
 | 
				
			||||||
 | 
					                       == original_line_item.id),
 | 
				
			||||||
 | 
					                    JournalEntryLineItem.id.not_in(existing_line_item_id)))
 | 
				
			||||||
 | 
					        if offset_total_but_form is None:
 | 
				
			||||||
 | 
					            offset_total_but_form = Decimal("0")
 | 
				
			||||||
 | 
					        offset_total_on_form: Decimal = sum(
 | 
				
			||||||
 | 
					            [x.amount.data for x in form.journal_entry_form.line_items
 | 
				
			||||||
 | 
					             if x.original_line_item_id.data == original_line_item.id
 | 
				
			||||||
 | 
					             and x.amount != field and x.amount.data is not None])
 | 
				
			||||||
 | 
					        net_balance: Decimal = original_line_item.amount \
 | 
				
			||||||
 | 
					            - offset_total_but_form - offset_total_on_form
 | 
				
			||||||
 | 
					        if field.data > net_balance:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The amount must not exceed the net balance %(balance)s of the"
 | 
				
			||||||
 | 
					                " original line item.", balance=format_amount(net_balance)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotLessThanOffsetTotal:
 | 
				
			||||||
 | 
					    """The validator to check if the amount is less than the offset total."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
 | 
				
			||||||
 | 
					        assert isinstance(form, LineItemForm)
 | 
				
			||||||
 | 
					        if field.data is None or form.id.data is None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        is_debit: bool = isinstance(form, DebitLineItemForm)
 | 
				
			||||||
 | 
					        select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
 | 
				
			||||||
 | 
					            (JournalEntryLineItem.is_debit != is_debit,
 | 
				
			||||||
 | 
					             JournalEntryLineItem.amount),
 | 
				
			||||||
 | 
					            else_=-JournalEntryLineItem.amount)))\
 | 
				
			||||||
 | 
					            .filter(be(JournalEntryLineItem.original_line_item_id
 | 
				
			||||||
 | 
					                       == form.id.data))
 | 
				
			||||||
 | 
					        offset_total: Decimal | None = db.session.scalar(select_offset_total)
 | 
				
			||||||
 | 
					        if offset_total is not None and field.data < offset_total:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The amount must not be less than the offset total %(total)s.",
 | 
				
			||||||
 | 
					                total=format_amount(offset_total)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LineItemForm(FlaskForm):
 | 
				
			||||||
 | 
					    """The base form to create or edit a line item."""
 | 
				
			||||||
 | 
					    id = IntegerField()
 | 
				
			||||||
 | 
					    """The existing line item ID."""
 | 
				
			||||||
 | 
					    no = IntegerField()
 | 
				
			||||||
 | 
					    """The order in the currency."""
 | 
				
			||||||
 | 
					    original_line_item_id = IntegerField()
 | 
				
			||||||
 | 
					    """The Id of the original line item."""
 | 
				
			||||||
 | 
					    account_code = StringField()
 | 
				
			||||||
 | 
					    """The account code."""
 | 
				
			||||||
 | 
					    description = StringField()
 | 
				
			||||||
 | 
					    """The description."""
 | 
				
			||||||
 | 
					    amount = DecimalField()
 | 
				
			||||||
 | 
					    """The amount."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        """Constructs a base line item form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param args: The arguments.
 | 
				
			||||||
 | 
					        :param kwargs: The keyword arguments.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        from .journal_entry import JournalEntryForm
 | 
				
			||||||
 | 
					        self.journal_entry_form: JournalEntryForm | None = None
 | 
				
			||||||
 | 
					        """The source journal entry form."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def account_text(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the text representation of the account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The text representation of the account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if self.account_code.data is None:
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					        account: Account | None = Account.find_by_code(self.account_code.data)
 | 
				
			||||||
 | 
					        if account is None:
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					        return str(account)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def __original_line_item(self) -> JournalEntryLineItem | None:
 | 
				
			||||||
 | 
					        """Returns the original line item.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The original line item.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not hasattr(self, "____original_line_item"):
 | 
				
			||||||
 | 
					            def get_line_item() -> JournalEntryLineItem | None:
 | 
				
			||||||
 | 
					                if self.original_line_item_id.data is None:
 | 
				
			||||||
 | 
					                    return None
 | 
				
			||||||
 | 
					                return db.session.get(JournalEntryLineItem,
 | 
				
			||||||
 | 
					                                      self.original_line_item_id.data)
 | 
				
			||||||
 | 
					            setattr(self, "____original_line_item", get_line_item())
 | 
				
			||||||
 | 
					        return getattr(self, "____original_line_item")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def original_line_item_date(self) -> date | None:
 | 
				
			||||||
 | 
					        """Returns the text representation of the original line item.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The text representation of the original line item.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return None if self.__original_line_item is None \
 | 
				
			||||||
 | 
					            else self.__original_line_item.journal_entry.date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def original_line_item_text(self) -> str | None:
 | 
				
			||||||
 | 
					        """Returns the text representation of the original line item.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The text representation of the original line item.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return None if self.__original_line_item is None \
 | 
				
			||||||
 | 
					            else str(self.__original_line_item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_need_offset(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the line item needs offset.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the line item needs offset, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if self.account_code.data is None:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        if self.account_code.data[0] == "1":
 | 
				
			||||||
 | 
					            if isinstance(self, CreditLineItemForm):
 | 
				
			||||||
 | 
					                return False
 | 
				
			||||||
 | 
					        elif self.account_code.data[0] == "2":
 | 
				
			||||||
 | 
					            if isinstance(self, DebitLineItemForm):
 | 
				
			||||||
 | 
					                return False
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        account: Account | None = Account.find_by_code(self.account_code.data)
 | 
				
			||||||
 | 
					        return account is not None and account.is_need_offset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def offsets(self) -> list[JournalEntryLineItem]:
 | 
				
			||||||
 | 
					        """Returns the offsets.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The offsets.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not hasattr(self, "__offsets"):
 | 
				
			||||||
 | 
					            def get_offsets() -> list[JournalEntryLineItem]:
 | 
				
			||||||
 | 
					                if not self.is_need_offset or self.id.data is None:
 | 
				
			||||||
 | 
					                    return []
 | 
				
			||||||
 | 
					                return JournalEntryLineItem.query\
 | 
				
			||||||
 | 
					                    .filter(JournalEntryLineItem.original_line_item_id
 | 
				
			||||||
 | 
					                            == self.id.data)\
 | 
				
			||||||
 | 
					                    .options(selectinload(JournalEntryLineItem.journal_entry),
 | 
				
			||||||
 | 
					                             selectinload(JournalEntryLineItem.account),
 | 
				
			||||||
 | 
					                             selectinload(JournalEntryLineItem.offsets)
 | 
				
			||||||
 | 
					                             .selectinload(
 | 
				
			||||||
 | 
					                                 JournalEntryLineItem.journal_entry)).all()
 | 
				
			||||||
 | 
					            setattr(self, "__offsets", get_offsets())
 | 
				
			||||||
 | 
					        return getattr(self, "__offsets")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def offset_total(self) -> Decimal | None:
 | 
				
			||||||
 | 
					        """Returns the total amount of the offsets.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The total amount of the offsets.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not hasattr(self, "__offset_total"):
 | 
				
			||||||
 | 
					            def get_offset_total():
 | 
				
			||||||
 | 
					                if not self.is_need_offset or self.id.data is None:
 | 
				
			||||||
 | 
					                    return None
 | 
				
			||||||
 | 
					                is_debit: bool = isinstance(self, DebitLineItemForm)
 | 
				
			||||||
 | 
					                return sum([x.amount if x.is_debit != is_debit else -x.amount
 | 
				
			||||||
 | 
					                            for x in self.offsets])
 | 
				
			||||||
 | 
					            setattr(self, "__offset_total", get_offset_total())
 | 
				
			||||||
 | 
					        return getattr(self, "__offset_total")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def net_balance(self) -> Decimal | None:
 | 
				
			||||||
 | 
					        """Returns the net balance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The net balance.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not self.is_need_offset or self.id.data is None \
 | 
				
			||||||
 | 
					                or self.amount.data is None:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        return self.amount.data - self.offset_total
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def all_errors(self) -> list[str | LazyString]:
 | 
				
			||||||
 | 
					        """Returns all the errors of the form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: All the errors of the form.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        all_errors: list[str | LazyString] = []
 | 
				
			||||||
 | 
					        for key in self.errors:
 | 
				
			||||||
 | 
					            if key != "csrf_token":
 | 
				
			||||||
 | 
					                all_errors.extend(self.errors[key])
 | 
				
			||||||
 | 
					        return all_errors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DebitLineItemForm(LineItemForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a debit line item."""
 | 
				
			||||||
 | 
					    id = IntegerField()
 | 
				
			||||||
 | 
					    """The existing line item ID."""
 | 
				
			||||||
 | 
					    no = IntegerField()
 | 
				
			||||||
 | 
					    """The order in the currency."""
 | 
				
			||||||
 | 
					    original_line_item_id = IntegerField(
 | 
				
			||||||
 | 
					        validators=[Optional(),
 | 
				
			||||||
 | 
					                    OriginalLineItemExists(),
 | 
				
			||||||
 | 
					                    OriginalLineItemOppositeDebitCredit(),
 | 
				
			||||||
 | 
					                    OriginalLineItemNeedOffset(),
 | 
				
			||||||
 | 
					                    OriginalLineItemNotOffset()])
 | 
				
			||||||
 | 
					    """The ID of the original line item."""
 | 
				
			||||||
 | 
					    account_code = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[ACCOUNT_REQUIRED,
 | 
				
			||||||
 | 
					                    AccountExists(),
 | 
				
			||||||
 | 
					                    IsDebitAccount(),
 | 
				
			||||||
 | 
					                    SameAccountAsOriginalLineItem(),
 | 
				
			||||||
 | 
					                    KeepAccountWhenHavingOffset(),
 | 
				
			||||||
 | 
					                    NotStartPayableFromDebit()])
 | 
				
			||||||
 | 
					    """The account code."""
 | 
				
			||||||
 | 
					    description = StringField(filters=[strip_text])
 | 
				
			||||||
 | 
					    """The description."""
 | 
				
			||||||
 | 
					    amount = DecimalField(
 | 
				
			||||||
 | 
					        validators=[PositiveAmount(),
 | 
				
			||||||
 | 
					                    NotExceedingOriginalLineItemNetBalance(),
 | 
				
			||||||
 | 
					                    NotLessThanOffsetTotal()])
 | 
				
			||||||
 | 
					    """The amount."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_obj(self, obj: JournalEntryLineItem) -> None:
 | 
				
			||||||
 | 
					        """Populates the form data into a line item object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param obj: The line item object.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        is_new: bool = obj.id is None
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            obj.id = new_id(JournalEntryLineItem)
 | 
				
			||||||
 | 
					        obj.original_line_item_id = self.original_line_item_id.data
 | 
				
			||||||
 | 
					        obj.account_id = Account.find_by_code(self.account_code.data).id
 | 
				
			||||||
 | 
					        obj.description = self.description.data
 | 
				
			||||||
 | 
					        obj.is_debit = True
 | 
				
			||||||
 | 
					        obj.amount = self.amount.data
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
 | 
					            obj.created_by_id = current_user_pk
 | 
				
			||||||
 | 
					            obj.updated_by_id = current_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CreditLineItemForm(LineItemForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a credit line item."""
 | 
				
			||||||
 | 
					    id = IntegerField()
 | 
				
			||||||
 | 
					    """The existing line item ID."""
 | 
				
			||||||
 | 
					    no = IntegerField()
 | 
				
			||||||
 | 
					    """The order in the currency."""
 | 
				
			||||||
 | 
					    original_line_item_id = IntegerField(
 | 
				
			||||||
 | 
					        validators=[Optional(),
 | 
				
			||||||
 | 
					                    OriginalLineItemExists(),
 | 
				
			||||||
 | 
					                    OriginalLineItemOppositeDebitCredit(),
 | 
				
			||||||
 | 
					                    OriginalLineItemNeedOffset(),
 | 
				
			||||||
 | 
					                    OriginalLineItemNotOffset()])
 | 
				
			||||||
 | 
					    """The ID of the original line item."""
 | 
				
			||||||
 | 
					    account_code = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[ACCOUNT_REQUIRED,
 | 
				
			||||||
 | 
					                    AccountExists(),
 | 
				
			||||||
 | 
					                    IsCreditAccount(),
 | 
				
			||||||
 | 
					                    SameAccountAsOriginalLineItem(),
 | 
				
			||||||
 | 
					                    KeepAccountWhenHavingOffset(),
 | 
				
			||||||
 | 
					                    NotStartReceivableFromCredit()])
 | 
				
			||||||
 | 
					    """The account code."""
 | 
				
			||||||
 | 
					    description = StringField(filters=[strip_text])
 | 
				
			||||||
 | 
					    """The description."""
 | 
				
			||||||
 | 
					    amount = DecimalField(
 | 
				
			||||||
 | 
					        validators=[PositiveAmount(),
 | 
				
			||||||
 | 
					                    NotExceedingOriginalLineItemNetBalance(),
 | 
				
			||||||
 | 
					                    NotLessThanOffsetTotal()])
 | 
				
			||||||
 | 
					    """The amount."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_obj(self, obj: JournalEntryLineItem) -> None:
 | 
				
			||||||
 | 
					        """Populates the form data into a line item object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param obj: The line item object.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        is_new: bool = obj.id is None
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            obj.id = new_id(JournalEntryLineItem)
 | 
				
			||||||
 | 
					        obj.original_line_item_id = self.original_line_item_id.data
 | 
				
			||||||
 | 
					        obj.account_id = Account.find_by_code(self.account_code.data).id
 | 
				
			||||||
 | 
					        obj.description = self.description.data
 | 
				
			||||||
 | 
					        obj.is_debit = False
 | 
				
			||||||
 | 
					        obj.amount = self.amount.data
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
 | 
					            obj.created_by_id = current_user_pk
 | 
				
			||||||
 | 
					            obj.updated_by_id = current_user_pk
 | 
				
			||||||
							
								
								
									
										95
									
								
								src/accounting/journal_entry/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/accounting/journal_entry/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,95 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The reorder forms for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from datetime import date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import db
 | 
				
			||||||
 | 
					from accounting.models import JournalEntry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def sort_journal_entries_in(journal_entry_date: date,
 | 
				
			||||||
 | 
					                            exclude: int | None = None) -> None:
 | 
				
			||||||
 | 
					    """Sorts the journal entries under a date after changing the date or
 | 
				
			||||||
 | 
					    deleting a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry_date: The date of the journal entry.
 | 
				
			||||||
 | 
					    :param exclude: The journal entry ID to exclude.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
 | 
					        = [JournalEntry.date == journal_entry_date]
 | 
				
			||||||
 | 
					    if exclude is not None:
 | 
				
			||||||
 | 
					        conditions.append(JournalEntry.id != exclude)
 | 
				
			||||||
 | 
					    journal_entries: list[JournalEntry] = JournalEntry.query\
 | 
				
			||||||
 | 
					        .filter(*conditions)\
 | 
				
			||||||
 | 
					        .order_by(JournalEntry.no).all()
 | 
				
			||||||
 | 
					    for i in range(len(journal_entries)):
 | 
				
			||||||
 | 
					        if journal_entries[i].no != i + 1:
 | 
				
			||||||
 | 
					            journal_entries[i].no = i + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class JournalEntryReorderForm:
 | 
				
			||||||
 | 
					    """The form to reorder the journal entries."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, journal_entry_date: date):
 | 
				
			||||||
 | 
					        """Constructs the form to reorder the journal entries in a day.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry_date: The date.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.date: date = journal_entry_date
 | 
				
			||||||
 | 
					        self.is_modified: bool = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save_order(self) -> None:
 | 
				
			||||||
 | 
					        """Saves the order of the account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        journal_entries: list[JournalEntry] = JournalEntry.query\
 | 
				
			||||||
 | 
					            .filter(JournalEntry.date == self.date).all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Collects the specified order.
 | 
				
			||||||
 | 
					        orders: dict[JournalEntry, int] = {}
 | 
				
			||||||
 | 
					        for journal_entry in journal_entries:
 | 
				
			||||||
 | 
					            if f"{journal_entry.id}-no" in request.form:
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    orders[journal_entry] \
 | 
				
			||||||
 | 
					                        = int(request.form[f"{journal_entry.id}-no"])
 | 
				
			||||||
 | 
					                except ValueError:
 | 
				
			||||||
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Missing and invalid orders are appended to the end.
 | 
				
			||||||
 | 
					        missing: list[JournalEntry] \
 | 
				
			||||||
 | 
					            = [x for x in journal_entries if x not in orders]
 | 
				
			||||||
 | 
					        if len(missing) > 0:
 | 
				
			||||||
 | 
					            next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
 | 
				
			||||||
 | 
					            for journal_entry in missing:
 | 
				
			||||||
 | 
					                orders[journal_entry] = next_no
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Sort by the specified order first, and their original order.
 | 
				
			||||||
 | 
					        journal_entries.sort(key=lambda x: (orders[x], x.no))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Update the orders.
 | 
				
			||||||
 | 
					        with db.session.no_autoflush:
 | 
				
			||||||
 | 
					            for i in range(len(journal_entries)):
 | 
				
			||||||
 | 
					                if journal_entries[i].no != i + 1:
 | 
				
			||||||
 | 
					                    journal_entries[i].no = i + 1
 | 
				
			||||||
 | 
					                    self.is_modified = True
 | 
				
			||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
#  See the License for the specific language governing permissions and
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
#  limitations under the License.
 | 
					#  limitations under the License.
 | 
				
			||||||
"""The template filters for the transaction management.
 | 
					"""The template filters for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
from decimal import Decimal
 | 
					from decimal import Decimal
 | 
				
			||||||
@@ -26,10 +26,10 @@ from flask import request
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def with_type(uri: str) -> str:
 | 
					def with_type(uri: str) -> str:
 | 
				
			||||||
    """Adds the transaction type to the URI, if it is specified.
 | 
					    """Adds the journal entry type to the URI, if it is specified.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param uri: The URI.
 | 
					    :param uri: The URI.
 | 
				
			||||||
    :return: The result URL, optionally with the transaction type added.
 | 
					    :return: The result URL, optionally with the journal entry type added.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    if "as" not in request.args:
 | 
					    if "as" not in request.args:
 | 
				
			||||||
        return uri
 | 
					        return uri
 | 
				
			||||||
@@ -43,10 +43,10 @@ def with_type(uri: str) -> str:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def to_transfer(uri: str) -> str:
 | 
					def to_transfer(uri: str) -> str:
 | 
				
			||||||
    """Adds the transfer transaction type to the URI.
 | 
					    """Adds the transfer journal entry type to the URI.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param uri: The URI.
 | 
					    :param uri: The URI.
 | 
				
			||||||
    :return: The result URL, with the transfer transaction type added.
 | 
					    :return: The result URL, with the transfer journal entry type added.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    uri_p: ParseResult = urlparse(uri)
 | 
					    uri_p: ParseResult = urlparse(uri)
 | 
				
			||||||
    params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
					    params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
				
			||||||
							
								
								
									
										19
									
								
								src/accounting/journal_entry/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/accounting/journal_entry/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The utilities for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
							
								
								
									
										49
									
								
								src/accounting/journal_entry/utils/account_option.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/accounting/journal_entry/utils/account_option.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The account option for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from accounting.models import Account
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountOption:
 | 
				
			||||||
 | 
					    """An account option."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, account: Account):
 | 
				
			||||||
 | 
					        """Constructs an account option.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param account: The account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.id: str = account.id
 | 
				
			||||||
 | 
					        """The account ID."""
 | 
				
			||||||
 | 
					        self.code: str = account.code
 | 
				
			||||||
 | 
					        """The account code."""
 | 
				
			||||||
 | 
					        self.query_values: list[str] = account.query_values
 | 
				
			||||||
 | 
					        """The values to be queried."""
 | 
				
			||||||
 | 
					        self.__str: str = str(account)
 | 
				
			||||||
 | 
					        """The string representation of the account option."""
 | 
				
			||||||
 | 
					        self.is_in_use: bool = False
 | 
				
			||||||
 | 
					        """True if this account is in use, or False otherwise."""
 | 
				
			||||||
 | 
					        self.is_need_offset: bool = account.is_need_offset
 | 
				
			||||||
 | 
					        """True if this account needs offset, or False otherwise."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the string representation of the account option.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The string representation of the account option.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return self.__str
 | 
				
			||||||
@@ -14,22 +14,24 @@
 | 
				
			|||||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
#  See the License for the specific language governing permissions and
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
#  limitations under the License.
 | 
					#  limitations under the License.
 | 
				
			||||||
"""The summary editor.
 | 
					"""The description editor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
import typing as t
 | 
					import typing as t
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import sqlalchemy as sa
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import current_app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.models import Account, JournalEntry
 | 
					from accounting.models import Account, JournalEntryLineItem
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SummaryAccount:
 | 
					class DescriptionAccount:
 | 
				
			||||||
    """An account for a summary tag."""
 | 
					    """An account for a description tag."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, account: Account, freq: int):
 | 
					    def __init__(self, account: Account, freq: int):
 | 
				
			||||||
        """Constructs an account for a summary tag.
 | 
					        """Constructs an account for a description tag.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param account: The account.
 | 
					        :param account: The account.
 | 
				
			||||||
        :param freq: The frequency of the tag with the account.
 | 
					        :param freq: The frequency of the tag with the account.
 | 
				
			||||||
@@ -59,17 +61,17 @@ class SummaryAccount:
 | 
				
			|||||||
        self.freq = self.freq + freq
 | 
					        self.freq = self.freq + freq
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SummaryTag:
 | 
					class DescriptionTag:
 | 
				
			||||||
    """A summary tag."""
 | 
					    """A description tag."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, name: str):
 | 
					    def __init__(self, name: str):
 | 
				
			||||||
        """Constructs a summary tag.
 | 
					        """Constructs a description tag.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param name: The tag name.
 | 
					        :param name: The tag name.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.name: str = name
 | 
					        self.name: str = name
 | 
				
			||||||
        """The tag name."""
 | 
					        """The tag name."""
 | 
				
			||||||
        self.__account_dict: dict[int, SummaryAccount] = {}
 | 
					        self.__account_dict: dict[int, DescriptionAccount] = {}
 | 
				
			||||||
        """The accounts that come with the tag, in the order of their
 | 
					        """The accounts that come with the tag, in the order of their
 | 
				
			||||||
        frequency."""
 | 
					        frequency."""
 | 
				
			||||||
        self.freq: int = 0
 | 
					        self.freq: int = 0
 | 
				
			||||||
@@ -89,11 +91,11 @@ class SummaryTag:
 | 
				
			|||||||
        :param freq: The frequency of the tag name with the account.
 | 
					        :param freq: The frequency of the tag name with the account.
 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.__account_dict[account.id] = SummaryAccount(account, freq)
 | 
					        self.__account_dict[account.id] = DescriptionAccount(account, freq)
 | 
				
			||||||
        self.freq = self.freq + freq
 | 
					        self.freq = self.freq + freq
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def accounts(self) -> list[SummaryAccount]:
 | 
					    def accounts(self) -> list[DescriptionAccount]:
 | 
				
			||||||
        """Returns the accounts by the order of their frequencies.
 | 
					        """Returns the accounts by the order of their frequencies.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The accounts by the order of their frequencies.
 | 
					        :return: The accounts by the order of their frequencies.
 | 
				
			||||||
@@ -109,17 +111,17 @@ class SummaryTag:
 | 
				
			|||||||
        return [x.code for x in self.accounts]
 | 
					        return [x.code for x in self.accounts]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SummaryType:
 | 
					class DescriptionType:
 | 
				
			||||||
    """A summary type"""
 | 
					    """A description type"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
 | 
					    def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
 | 
				
			||||||
        """Constructs a summary type.
 | 
					        """Constructs a description type.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param type_id: The type ID, either "general", "travel", or "bus".
 | 
					        :param type_id: The type ID, either "general", "travel", or "bus".
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.id: t.Literal["general", "travel", "bus"] = type_id
 | 
					        self.id: t.Literal["general", "travel", "bus"] = type_id
 | 
				
			||||||
        """The type ID."""
 | 
					        """The type ID."""
 | 
				
			||||||
        self.__tag_dict: dict[str, SummaryTag] = {}
 | 
					        self.__tag_dict: dict[str, DescriptionTag] = {}
 | 
				
			||||||
        """A dictionary from the tag name to their corresponding tag."""
 | 
					        """A dictionary from the tag name to their corresponding tag."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def add_tag(self, name: str, account: Account, freq: int) -> None:
 | 
					    def add_tag(self, name: str, account: Account, freq: int) -> None:
 | 
				
			||||||
@@ -131,11 +133,11 @@ class SummaryType:
 | 
				
			|||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if name not in self.__tag_dict:
 | 
					        if name not in self.__tag_dict:
 | 
				
			||||||
            self.__tag_dict[name] = SummaryTag(name)
 | 
					            self.__tag_dict[name] = DescriptionTag(name)
 | 
				
			||||||
        self.__tag_dict[name].add_account(account, freq)
 | 
					        self.__tag_dict[name].add_account(account, freq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def tags(self) -> list[SummaryTag]:
 | 
					    def tags(self) -> list[DescriptionTag]:
 | 
				
			||||||
        """Returns the tags by the order of their frequencies.
 | 
					        """Returns the tags by the order of their frequencies.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The tags by the order of their frequencies.
 | 
					        :return: The tags by the order of their frequencies.
 | 
				
			||||||
@@ -143,26 +145,51 @@ class SummaryType:
 | 
				
			|||||||
        return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
 | 
					        return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SummaryEntryType:
 | 
					class DescriptionRecurring:
 | 
				
			||||||
    """A summary type"""
 | 
					    """A recurring transaction."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
 | 
					    def __init__(self, name: str, template: str, account: Account):
 | 
				
			||||||
        """Constructs a summary entry type.
 | 
					        """Constructs a recurring transaction.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param entry_type_id: The entry type ID, either "debit" or "credit".
 | 
					        :param name: The name.
 | 
				
			||||||
 | 
					        :param template: The template.
 | 
				
			||||||
 | 
					        :param account: The account.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.type: t.Literal["debit", "credit"] = entry_type_id
 | 
					        self.name: str = name
 | 
				
			||||||
        """The entry type."""
 | 
					        self.template: str = template
 | 
				
			||||||
        self.general: SummaryType = SummaryType("general")
 | 
					        self.account: DescriptionAccount = DescriptionAccount(account, 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def account_codes(self) -> list[str]:
 | 
				
			||||||
 | 
					        """Returns the account codes by the order of their frequencies.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The account codes by the order of their frequencies.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [self.account.code]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DescriptionDebitCredit:
 | 
				
			||||||
 | 
					    """The description on debit or credit."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, debit_credit: t.Literal["debit", "credit"]):
 | 
				
			||||||
 | 
					        """Constructs the description on debit or credit.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param debit_credit: Either "debit" or "credit".
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.debit_credit: t.Literal["debit", "credit"] = debit_credit
 | 
				
			||||||
 | 
					        """Either debit or credit."""
 | 
				
			||||||
 | 
					        self.general: DescriptionType = DescriptionType("general")
 | 
				
			||||||
        """The general tags."""
 | 
					        """The general tags."""
 | 
				
			||||||
        self.travel: SummaryType = SummaryType("travel")
 | 
					        self.travel: DescriptionType = DescriptionType("travel")
 | 
				
			||||||
        """The travel tags."""
 | 
					        """The travel tags."""
 | 
				
			||||||
        self.bus: SummaryType = SummaryType("bus")
 | 
					        self.bus: DescriptionType = DescriptionType("bus")
 | 
				
			||||||
        """The bus tags."""
 | 
					        """The bus tags."""
 | 
				
			||||||
        self.__type_dict: dict[t.Literal["general", "travel", "bus"],
 | 
					        self.__type_dict: dict[t.Literal["general", "travel", "bus"],
 | 
				
			||||||
                               SummaryType] \
 | 
					                               DescriptionType] \
 | 
				
			||||||
            = {x.id: x for x in {self.general, self.travel, self.bus}}
 | 
					            = {x.id: x for x in {self.general, self.travel, self.bus}}
 | 
				
			||||||
        """A dictionary from the type ID to the corresponding tags."""
 | 
					        """A dictionary from the type ID to the corresponding tags."""
 | 
				
			||||||
 | 
					        self.recurring: list[DescriptionRecurring] = []
 | 
				
			||||||
 | 
					        """The recurring transactions."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
 | 
					    def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
 | 
				
			||||||
                name: str, account: Account, freq: int) -> None:
 | 
					                name: str, account: Account, freq: int) -> None:
 | 
				
			||||||
@@ -177,13 +204,13 @@ class SummaryEntryType:
 | 
				
			|||||||
        self.__type_dict[tag_type].add_tag(name, account, freq)
 | 
					        self.__type_dict[tag_type].add_tag(name, account, freq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def accounts(self) -> list[SummaryAccount]:
 | 
					    def accounts(self) -> list[DescriptionAccount]:
 | 
				
			||||||
        """Returns the suggested accounts of all tags in the summary editor in
 | 
					        """Returns the suggested accounts of all tags in the description editor
 | 
				
			||||||
        the entry type, in their frequency order.
 | 
					        in debit or credit, in their frequency order.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The suggested accounts of all tags, in their frequency order.
 | 
					        :return: The suggested accounts of all tags, in their frequency order.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        accounts: dict[int, SummaryAccount] = {}
 | 
					        accounts: dict[int, DescriptionAccount] = {}
 | 
				
			||||||
        freq: dict[int, int] = {}
 | 
					        freq: dict[int, int] = {}
 | 
				
			||||||
        for tag_type in self.__type_dict.values():
 | 
					        for tag_type in self.__type_dict.values():
 | 
				
			||||||
            for tag in tag_type.tags:
 | 
					            for tag in tag_type.tags:
 | 
				
			||||||
@@ -193,43 +220,104 @@ class SummaryEntryType:
 | 
				
			|||||||
                        freq[account.id] = 0
 | 
					                        freq[account.id] = 0
 | 
				
			||||||
                    freq[account.id] \
 | 
					                    freq[account.id] \
 | 
				
			||||||
                        = freq[account.id] + account.freq
 | 
					                        = freq[account.id] + account.freq
 | 
				
			||||||
 | 
					        for recurring in self.recurring:
 | 
				
			||||||
 | 
					            accounts[recurring.account.id] = recurring.account
 | 
				
			||||||
 | 
					            if recurring.account.id not in freq:
 | 
				
			||||||
 | 
					                freq[recurring.account.id] = 0
 | 
				
			||||||
        return [accounts[y] for y in sorted(freq.keys(),
 | 
					        return [accounts[y] for y in sorted(freq.keys(),
 | 
				
			||||||
                                            key=lambda x: -freq[x])]
 | 
					                                            key=lambda x: -freq[x])]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SummaryEditor:
 | 
					class DescriptionEditor:
 | 
				
			||||||
    """The summary editor."""
 | 
					    """The description editor."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self):
 | 
					    def __init__(self):
 | 
				
			||||||
        """Constructs the summary editor."""
 | 
					        """Constructs the description editor."""
 | 
				
			||||||
        self.debit: SummaryEntryType = SummaryEntryType("debit")
 | 
					        self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
 | 
				
			||||||
        """The debit tags."""
 | 
					        """The debit tags."""
 | 
				
			||||||
        self.credit: SummaryEntryType = SummaryEntryType("credit")
 | 
					        self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
 | 
				
			||||||
        """The credit tags."""
 | 
					        """The credit tags."""
 | 
				
			||||||
        entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
 | 
					        self.__init_tags()
 | 
				
			||||||
                                       else_="credit").label("entry_type")
 | 
					        self.__init_recurring()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init_tags(self):
 | 
				
			||||||
 | 
					        """Initializes the tags.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        debit_credit: sa.Label = sa.case(
 | 
				
			||||||
 | 
					            (JournalEntryLineItem.is_debit, "debit"),
 | 
				
			||||||
 | 
					            else_="credit").label("debit_credit")
 | 
				
			||||||
        tag_type: sa.Label = sa.case(
 | 
					        tag_type: sa.Label = sa.case(
 | 
				
			||||||
            (JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
 | 
					            (JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
 | 
				
			||||||
            (sa.or_(JournalEntry.summary.like("_%—_%→_%"),
 | 
					            (sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
 | 
				
			||||||
                    JournalEntry.summary.like("_%—_%↔_%")), "travel"),
 | 
					                    JournalEntryLineItem.description.like("_%—_%↔_%")),
 | 
				
			||||||
 | 
					             "travel"),
 | 
				
			||||||
            else_="general").label("tag_type")
 | 
					            else_="general").label("tag_type")
 | 
				
			||||||
        tag: sa.Label = get_prefix(JournalEntry.summary, "—").label("tag")
 | 
					        tag: sa.Label = get_prefix(JournalEntryLineItem.description, "—")\
 | 
				
			||||||
        select: sa.Select = sa.Select(entry_type, tag_type, tag,
 | 
					            .label("tag")
 | 
				
			||||||
                                      JournalEntry.account_id,
 | 
					        select: sa.Select = sa.Select(debit_credit, tag_type, tag,
 | 
				
			||||||
 | 
					                                      JournalEntryLineItem.account_id,
 | 
				
			||||||
                                      sa.func.count().label("freq"))\
 | 
					                                      sa.func.count().label("freq"))\
 | 
				
			||||||
            .filter(JournalEntry.summary.is_not(None),
 | 
					            .filter(JournalEntryLineItem.description.is_not(None),
 | 
				
			||||||
                    JournalEntry.summary.like("_%—_%"))\
 | 
					                    JournalEntryLineItem.description.like("_%—_%"),
 | 
				
			||||||
            .group_by(entry_type, tag_type, tag, JournalEntry.account_id)
 | 
					                    JournalEntryLineItem.original_line_item_id.is_(None))\
 | 
				
			||||||
 | 
					            .group_by(debit_credit, tag_type, tag,
 | 
				
			||||||
 | 
					                      JournalEntryLineItem.account_id)
 | 
				
			||||||
        result: list[sa.Row] = db.session.execute(select).all()
 | 
					        result: list[sa.Row] = db.session.execute(select).all()
 | 
				
			||||||
        accounts: dict[int, Account] \
 | 
					        accounts: dict[int, Account] \
 | 
				
			||||||
            = {x.id: x for x in Account.query
 | 
					            = {x.id: x for x in Account.query
 | 
				
			||||||
               .filter(Account.id.in_({x.account_id for x in result})).all()}
 | 
					               .filter(Account.id.in_({x.account_id for x in result})).all()}
 | 
				
			||||||
        entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
 | 
					        debit_credit_dict: dict[t.Literal["debit", "credit"],
 | 
				
			||||||
            = {x.type: x for x in {self.debit, self.credit}}
 | 
					                                DescriptionDebitCredit] \
 | 
				
			||||||
 | 
					            = {x.debit_credit: x for x in {self.debit, self.credit}}
 | 
				
			||||||
        for row in result:
 | 
					        for row in result:
 | 
				
			||||||
            entry_type_dict[row.entry_type].add_tag(
 | 
					            debit_credit_dict[row.debit_credit].add_tag(
 | 
				
			||||||
                row.tag_type, row.tag, accounts[row.account_id], row.freq)
 | 
					                row.tag_type, row.tag, accounts[row.account_id], row.freq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init_recurring(self) -> None:
 | 
				
			||||||
 | 
					        """Initializes the recurring transactions.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if "ACCOUNTING_RECURRING" not in current_app.config:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        data: list[tuple[t.Literal["debit", "credit"], str, str, str]] \
 | 
				
			||||||
 | 
					            = [x.split("|")
 | 
				
			||||||
 | 
					               for x in current_app.config["ACCOUNTING_RECURRING"].split(",")]
 | 
				
			||||||
 | 
					        debit_credit_dict: dict[t.Literal["debit", "credit"],
 | 
				
			||||||
 | 
					                                DescriptionDebitCredit] \
 | 
				
			||||||
 | 
					            = {x.debit_credit: x for x in {self.debit, self.credit}}
 | 
				
			||||||
 | 
					        accounts: dict[str, Account] \
 | 
				
			||||||
 | 
					            = self.__get_accounts({x[1] for x in data})
 | 
				
			||||||
 | 
					        for row in data:
 | 
				
			||||||
 | 
					            debit_credit_dict[row[0]].recurring.append(
 | 
				
			||||||
 | 
					                DescriptionRecurring(row[2], row[3], accounts[row[1]]))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def __get_accounts(codes: set[str]) -> dict[str, Account]:
 | 
				
			||||||
 | 
					        """Finds and returns the accounts by codes.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param codes: The account codes.
 | 
				
			||||||
 | 
					        :return: The account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        def get_condition(code0: str) -> sa.BinaryExpression:
 | 
				
			||||||
 | 
					            m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
 | 
				
			||||||
 | 
					            assert m is not None,\
 | 
				
			||||||
 | 
					                f"Malformed account code \"{code0}\" for regular transactions."
 | 
				
			||||||
 | 
					            return sa.and_(Account.base_code == m.group(1),
 | 
				
			||||||
 | 
					                           Account.no == int(m.group(2)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
 | 
					            = [get_condition(x) for x in codes]
 | 
				
			||||||
 | 
					        accounts: dict[str, Account] \
 | 
				
			||||||
 | 
					            = {x.code: x for x in
 | 
				
			||||||
 | 
					               Account.query.filter(sa.or_(*conditions)).all()}
 | 
				
			||||||
 | 
					        for code in codes:
 | 
				
			||||||
 | 
					            assert code in accounts,\
 | 
				
			||||||
 | 
					                f"Unknown account \"{code}\" for regular transactions."
 | 
				
			||||||
 | 
					        return accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
 | 
					def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
 | 
				
			||||||
        -> sa.Function:
 | 
					        -> sa.Function:
 | 
				
			||||||
							
								
								
									
										39
									
								
								src/accounting/journal_entry/utils/offset_alias.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/accounting/journal_entry/utils/offset_alias.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  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 SQLAlchemy alias for the offset items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.models import JournalEntryLineItem
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def offset_alias() -> sa.Alias:
 | 
				
			||||||
 | 
					    """Returns the SQLAlchemy alias for the offset items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The SQLAlchemy alias for the offset items.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def as_from(model_cls: t.Any) -> sa.FromClause:
 | 
				
			||||||
 | 
					        return model_cls
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def as_alias(alias: t.Any) -> sa.Alias:
 | 
				
			||||||
 | 
					        return alias
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset"))
 | 
				
			||||||
							
								
								
									
										336
									
								
								src/accounting/journal_entry/utils/operators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										336
									
								
								src/accounting/journal_entry/utils/operators.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,336 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The operators for different journal entry types.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					from abc import ABC, abstractmethod
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import render_template, request, abort
 | 
				
			||||||
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.models import JournalEntry
 | 
				
			||||||
 | 
					from accounting.template_globals import default_currency_code
 | 
				
			||||||
 | 
					from accounting.utils.journal_entry_types import JournalEntryType
 | 
				
			||||||
 | 
					from accounting.journal_entry.forms import JournalEntryForm, \
 | 
				
			||||||
 | 
					    CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
 | 
				
			||||||
 | 
					    TransferJournalEntryForm
 | 
				
			||||||
 | 
					from accounting.journal_entry.forms.line_item import LineItemForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class JournalEntryOperator(ABC):
 | 
				
			||||||
 | 
					    """The base journal entry operator."""
 | 
				
			||||||
 | 
					    CHECK_ORDER: int = -1
 | 
				
			||||||
 | 
					    """The order when checking the journal entry operator."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def form(self) -> t.Type[JournalEntryForm]:
 | 
				
			||||||
 | 
					        """Returns the form class.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The form class.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def render_create_template(self, form: FlaskForm) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to create a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param form: The journal entry form.
 | 
				
			||||||
 | 
					        :return: the form to create a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def render_detail_template(self, journal_entry: JournalEntry) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the detail page.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: the detail page.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def render_edit_template(self, journal_entry: JournalEntry,
 | 
				
			||||||
 | 
					                             form: FlaskForm) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to edit a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :param form: The form.
 | 
				
			||||||
 | 
					        :return: the form to edit a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def is_my_type(self, journal_entry: JournalEntry) -> bool:
 | 
				
			||||||
 | 
					        """Checks and returns whether the journal entry belongs to the type.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: True if the journal entry belongs to the type, or False
 | 
				
			||||||
 | 
					            otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def _line_item_template(self) -> str:
 | 
				
			||||||
 | 
					        """Renders and returns the template for the line item sub-form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The template for the line item sub-form.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/include/form-line-item.html",
 | 
				
			||||||
 | 
					            currency_index="CURRENCY_INDEX",
 | 
				
			||||||
 | 
					            debit_credit="DEBIT_CREDIT",
 | 
				
			||||||
 | 
					            line_item_index="LINE_ITEM_INDEX",
 | 
				
			||||||
 | 
					            form=LineItemForm())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CashReceiptJournalEntry(JournalEntryOperator):
 | 
				
			||||||
 | 
					    """A cash receipt journal entry."""
 | 
				
			||||||
 | 
					    CHECK_ORDER: int = 2
 | 
				
			||||||
 | 
					    """The order when checking the journal entry operator."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def form(self) -> t.Type[JournalEntryForm]:
 | 
				
			||||||
 | 
					        """Returns the form class.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The form class.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return CashReceiptJournalEntryForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_create_template(self, form: CashReceiptJournalEntryForm) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to create a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param form: The journal entry form.
 | 
				
			||||||
 | 
					        :return: the form to create a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/receipt/create.html",
 | 
				
			||||||
 | 
					            form=form,
 | 
				
			||||||
 | 
					            journal_entry_type=JournalEntryType.CASH_RECEIPT,
 | 
				
			||||||
 | 
					            currency_template=self.__currency_template,
 | 
				
			||||||
 | 
					            line_item_template=self._line_item_template)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_detail_template(self, journal_entry: JournalEntry) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the detail page.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: the detail page.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template("accounting/journal-entry/receipt/detail.html",
 | 
				
			||||||
 | 
					                               obj=journal_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_edit_template(self, journal_entry: JournalEntry,
 | 
				
			||||||
 | 
					                             form: CashReceiptJournalEntryForm) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to edit a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :param form: The form.
 | 
				
			||||||
 | 
					        :return: the form to edit a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template("accounting/journal-entry/receipt/edit.html",
 | 
				
			||||||
 | 
					                               journal_entry=journal_entry, form=form,
 | 
				
			||||||
 | 
					                               currency_template=self.__currency_template,
 | 
				
			||||||
 | 
					                               line_item_template=self._line_item_template)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_my_type(self, journal_entry: JournalEntry) -> bool:
 | 
				
			||||||
 | 
					        """Checks and returns whether the journal entry belongs to the type.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: True if the journal entry belongs to the type, or False
 | 
				
			||||||
 | 
					            otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return journal_entry.is_cash_receipt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def __currency_template(self) -> str:
 | 
				
			||||||
 | 
					        """Renders and returns the template for the currency sub-form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The template for the currency sub-form.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/receipt/include/form-currency.html",
 | 
				
			||||||
 | 
					            currency_index="CURRENCY_INDEX",
 | 
				
			||||||
 | 
					            currency_code_data=default_currency_code(),
 | 
				
			||||||
 | 
					            credit_total="-")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CashDisbursementJournalEntry(JournalEntryOperator):
 | 
				
			||||||
 | 
					    """A cash disbursement journal entry."""
 | 
				
			||||||
 | 
					    CHECK_ORDER: int = 1
 | 
				
			||||||
 | 
					    """The order when checking the journal entry operator."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def form(self) -> t.Type[JournalEntryForm]:
 | 
				
			||||||
 | 
					        """Returns the form class.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The form class.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return CashDisbursementJournalEntryForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_create_template(self, form: CashDisbursementJournalEntryForm) \
 | 
				
			||||||
 | 
					            -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to create a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param form: The journal entry form.
 | 
				
			||||||
 | 
					        :return: the form to create a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/disbursement/create.html",
 | 
				
			||||||
 | 
					            form=form,
 | 
				
			||||||
 | 
					            journal_entry_type=JournalEntryType.CASH_DISBURSEMENT,
 | 
				
			||||||
 | 
					            currency_template=self.__currency_template,
 | 
				
			||||||
 | 
					            line_item_template=self._line_item_template)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_detail_template(self, journal_entry: JournalEntry) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the detail page.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: the detail page.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/disbursement/detail.html",
 | 
				
			||||||
 | 
					            obj=journal_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_edit_template(self, journal_entry: JournalEntry,
 | 
				
			||||||
 | 
					                             form: CashDisbursementJournalEntryForm) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to edit a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :param form: The form.
 | 
				
			||||||
 | 
					        :return: the form to edit a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/disbursement/edit.html",
 | 
				
			||||||
 | 
					            journal_entry=journal_entry, form=form,
 | 
				
			||||||
 | 
					            currency_template=self.__currency_template,
 | 
				
			||||||
 | 
					            line_item_template=self._line_item_template)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_my_type(self, journal_entry: JournalEntry) -> bool:
 | 
				
			||||||
 | 
					        """Checks and returns whether the journal entry belongs to the type.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: True if the journal entry belongs to the type, or False
 | 
				
			||||||
 | 
					            otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return journal_entry.is_cash_disbursement
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def __currency_template(self) -> str:
 | 
				
			||||||
 | 
					        """Renders and returns the template for the currency sub-form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The template for the currency sub-form.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/disbursement/include/form-currency.html",
 | 
				
			||||||
 | 
					            currency_index="CURRENCY_INDEX",
 | 
				
			||||||
 | 
					            currency_code_data=default_currency_code(),
 | 
				
			||||||
 | 
					            debit_total="-")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TransferJournalEntry(JournalEntryOperator):
 | 
				
			||||||
 | 
					    """A transfer journal entry."""
 | 
				
			||||||
 | 
					    CHECK_ORDER: int = 3
 | 
				
			||||||
 | 
					    """The order when checking the journal entry operator."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def form(self) -> t.Type[JournalEntryForm]:
 | 
				
			||||||
 | 
					        """Returns the form class.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The form class.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return TransferJournalEntryForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_create_template(self, form: TransferJournalEntryForm) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to create a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param form: The journal entry form.
 | 
				
			||||||
 | 
					        :return: the form to create a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/transfer/create.html",
 | 
				
			||||||
 | 
					            form=form,
 | 
				
			||||||
 | 
					            journal_entry_type=JournalEntryType.TRANSFER,
 | 
				
			||||||
 | 
					            currency_template=self.__currency_template,
 | 
				
			||||||
 | 
					            line_item_template=self._line_item_template)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_detail_template(self, journal_entry: JournalEntry) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the detail page.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: the detail page.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template("accounting/journal-entry/transfer/detail.html",
 | 
				
			||||||
 | 
					                               obj=journal_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render_edit_template(self, journal_entry: JournalEntry,
 | 
				
			||||||
 | 
					                             form: TransferJournalEntryForm) -> str:
 | 
				
			||||||
 | 
					        """Renders the template for the form to edit a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :param form: The form.
 | 
				
			||||||
 | 
					        :return: the form to edit a journal entry.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template("accounting/journal-entry/transfer/edit.html",
 | 
				
			||||||
 | 
					                               journal_entry=journal_entry, form=form,
 | 
				
			||||||
 | 
					                               currency_template=self.__currency_template,
 | 
				
			||||||
 | 
					                               line_item_template=self._line_item_template)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_my_type(self, journal_entry: JournalEntry) -> bool:
 | 
				
			||||||
 | 
					        """Checks and returns whether the journal entry belongs to the type.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					        :return: True if the journal entry belongs to the type, or False
 | 
				
			||||||
 | 
					            otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def __currency_template(self) -> str:
 | 
				
			||||||
 | 
					        """Renders and returns the template for the currency sub-form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The template for the currency sub-form.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return render_template(
 | 
				
			||||||
 | 
					            "accounting/journal-entry/transfer/include/form-currency.html",
 | 
				
			||||||
 | 
					            currency_index="CURRENCY_INDEX",
 | 
				
			||||||
 | 
					            currency_code_data=default_currency_code(),
 | 
				
			||||||
 | 
					            debit_total="-", credit_total="-")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					JOURNAL_ENTRY_TYPE_TO_OP: dict[JournalEntryType, JournalEntryOperator] \
 | 
				
			||||||
 | 
					    = {JournalEntryType.CASH_RECEIPT: CashReceiptJournalEntry(),
 | 
				
			||||||
 | 
					       JournalEntryType.CASH_DISBURSEMENT: CashDisbursementJournalEntry(),
 | 
				
			||||||
 | 
					       JournalEntryType.TRANSFER: TransferJournalEntry()}
 | 
				
			||||||
 | 
					"""The map from the journal entry types to their operators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_journal_entry_op(journal_entry: JournalEntry,
 | 
				
			||||||
 | 
					                         is_check_as: bool = False) -> JournalEntryOperator:
 | 
				
			||||||
 | 
					    """Returns the journal entry operator that may be specified in the "as"
 | 
				
			||||||
 | 
					    query parameter.  If it is not specified, check the journal entry type from
 | 
				
			||||||
 | 
					    the journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					    :param is_check_as: True to check the "as" parameter, or False otherwise.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if is_check_as and "as" in request.args:
 | 
				
			||||||
 | 
					        type_dict: dict[str, JournalEntryType] \
 | 
				
			||||||
 | 
					            = {x.value: x for x in JournalEntryType}
 | 
				
			||||||
 | 
					        if request.args["as"] not in type_dict:
 | 
				
			||||||
 | 
					            abort(404)
 | 
				
			||||||
 | 
					        return JOURNAL_ENTRY_TYPE_TO_OP[type_dict[request.args["as"]]]
 | 
				
			||||||
 | 
					    for journal_entry_type in sorted(JOURNAL_ENTRY_TYPE_TO_OP.values(),
 | 
				
			||||||
 | 
					                                     key=lambda x: x.CHECK_ORDER):
 | 
				
			||||||
 | 
					        if journal_entry_type.is_my_type(journal_entry):
 | 
				
			||||||
 | 
					            return journal_entry_type
 | 
				
			||||||
							
								
								
									
										84
									
								
								src/accounting/journal_entry/utils/original_line_items.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/accounting/journal_entry/utils/original_line_items.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The selectable original line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from decimal import Decimal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from sqlalchemy.orm import selectinload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import db
 | 
				
			||||||
 | 
					from accounting.models import Account, JournalEntry, JournalEntryLineItem
 | 
				
			||||||
 | 
					from accounting.utils.cast import be
 | 
				
			||||||
 | 
					from .offset_alias import offset_alias
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_selectable_original_line_items(
 | 
				
			||||||
 | 
					        line_item_id_on_form: set[int], is_payable: bool,
 | 
				
			||||||
 | 
					        is_receivable: bool) -> list[JournalEntryLineItem]:
 | 
				
			||||||
 | 
					    """Queries and returns the selectable original line items, with their net
 | 
				
			||||||
 | 
					    balances.  The offset amounts of the form is excluded.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param line_item_id_on_form: The ID of the line items on the form.
 | 
				
			||||||
 | 
					    :param is_payable: True to check the payable original line items, or False
 | 
				
			||||||
 | 
					        otherwise.
 | 
				
			||||||
 | 
					    :param is_receivable: True to check the receivable original line items, or
 | 
				
			||||||
 | 
					        False otherwise.
 | 
				
			||||||
 | 
					    :return: The selectable original line items, with their net balances.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    assert is_payable or is_receivable
 | 
				
			||||||
 | 
					    offset: sa.Alias = offset_alias()
 | 
				
			||||||
 | 
					    net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
 | 
				
			||||||
 | 
					        (offset.c.id.in_(line_item_id_on_form), 0),
 | 
				
			||||||
 | 
					        (be(offset.c.is_debit == JournalEntryLineItem.is_debit),
 | 
				
			||||||
 | 
					         offset.c.amount),
 | 
				
			||||||
 | 
					        else_=-offset.c.amount))).label("net_balance")
 | 
				
			||||||
 | 
					    conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
 | 
				
			||||||
 | 
					    sub_conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
 | 
					    if is_payable:
 | 
				
			||||||
 | 
					        sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
 | 
				
			||||||
 | 
					                                      sa.not_(JournalEntryLineItem.is_debit)))
 | 
				
			||||||
 | 
					    if is_receivable:
 | 
				
			||||||
 | 
					        sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
 | 
				
			||||||
 | 
					                                      JournalEntryLineItem.is_debit))
 | 
				
			||||||
 | 
					    conditions.append(sa.or_(*sub_conditions))
 | 
				
			||||||
 | 
					    select_net_balances: sa.Select \
 | 
				
			||||||
 | 
					        = sa.select(JournalEntryLineItem.id, net_balance)\
 | 
				
			||||||
 | 
					        .join(Account)\
 | 
				
			||||||
 | 
					        .join(offset, be(JournalEntryLineItem.id
 | 
				
			||||||
 | 
					                         == offset.c.original_line_item_id),
 | 
				
			||||||
 | 
					              isouter=True)\
 | 
				
			||||||
 | 
					        .filter(*conditions)\
 | 
				
			||||||
 | 
					        .group_by(JournalEntryLineItem.id)\
 | 
				
			||||||
 | 
					        .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
 | 
				
			||||||
 | 
					    net_balances: dict[int, Decimal] \
 | 
				
			||||||
 | 
					        = {x.id: x.net_balance
 | 
				
			||||||
 | 
					           for x in db.session.execute(select_net_balances).all()}
 | 
				
			||||||
 | 
					    line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
 | 
				
			||||||
 | 
					        .filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
 | 
				
			||||||
 | 
					        .join(JournalEntry)\
 | 
				
			||||||
 | 
					        .order_by(JournalEntry.date, JournalEntryLineItem.is_debit,
 | 
				
			||||||
 | 
					                  JournalEntryLineItem.no)\
 | 
				
			||||||
 | 
					        .options(selectinload(JournalEntryLineItem.currency),
 | 
				
			||||||
 | 
					                 selectinload(JournalEntryLineItem.account),
 | 
				
			||||||
 | 
					                 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
 | 
				
			||||||
							
								
								
									
										238
									
								
								src/accounting/journal_entry/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								src/accounting/journal_entry/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,238 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The views for the journal entry management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from datetime import date
 | 
				
			||||||
 | 
					from urllib.parse import parse_qsl, urlencode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import Blueprint, render_template, session, redirect, request, \
 | 
				
			||||||
 | 
					    flash, url_for
 | 
				
			||||||
 | 
					from werkzeug.datastructures import ImmutableMultiDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import JournalEntry
 | 
				
			||||||
 | 
					from accounting.utils.cast import s
 | 
				
			||||||
 | 
					from accounting.utils.flash_errors import flash_form_errors
 | 
				
			||||||
 | 
					from accounting.utils.next_uri import inherit_next, or_next
 | 
				
			||||||
 | 
					from accounting.utils.permission import has_permission, can_view, can_edit
 | 
				
			||||||
 | 
					from accounting.utils.journal_entry_types import JournalEntryType
 | 
				
			||||||
 | 
					from accounting.utils.user import get_current_user_pk
 | 
				
			||||||
 | 
					from .forms import sort_journal_entries_in, JournalEntryReorderForm
 | 
				
			||||||
 | 
					from .template_filters import with_type, to_transfer, format_amount_input, \
 | 
				
			||||||
 | 
					    text2html
 | 
				
			||||||
 | 
					from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
 | 
				
			||||||
 | 
					    get_journal_entry_op
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					bp: Blueprint = Blueprint("journal-entry", __name__)
 | 
				
			||||||
 | 
					"""The view blueprint for the journal entry management."""
 | 
				
			||||||
 | 
					bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type")
 | 
				
			||||||
 | 
					bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer")
 | 
				
			||||||
 | 
					bp.add_app_template_filter(format_amount_input,
 | 
				
			||||||
 | 
					                           "accounting_journal_entry_format_amount_input")
 | 
				
			||||||
 | 
					bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
 | 
				
			||||||
 | 
					    """Shows the form to add a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry_type: The journal entry type.
 | 
				
			||||||
 | 
					    :return: The form to add a journal entry.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    journal_entry_op: JournalEntryOperator \
 | 
				
			||||||
 | 
					        = JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
 | 
				
			||||||
 | 
					    form: journal_entry_op.form
 | 
				
			||||||
 | 
					    if "form" in session:
 | 
				
			||||||
 | 
					        form = journal_entry_op.form(
 | 
				
			||||||
 | 
					            ImmutableMultiDict(parse_qsl(session["form"])))
 | 
				
			||||||
 | 
					        del session["form"]
 | 
				
			||||||
 | 
					        form.validate()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        form = journal_entry_op.form()
 | 
				
			||||||
 | 
					        form.date.data = date.today()
 | 
				
			||||||
 | 
					    return journal_entry_op.render_create_template(form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
 | 
				
			||||||
 | 
					    """Adds a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry_type: The journal entry type.
 | 
				
			||||||
 | 
					    :return: The redirection to the journal entry detail on success, or the
 | 
				
			||||||
 | 
					        journal entry creation form on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    journal_entry_op: JournalEntryOperator \
 | 
				
			||||||
 | 
					        = JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
 | 
				
			||||||
 | 
					    form: journal_entry_op.form = journal_entry_op.form(request.form)
 | 
				
			||||||
 | 
					    if not form.validate():
 | 
				
			||||||
 | 
					        flash_form_errors(form)
 | 
				
			||||||
 | 
					        session["form"] = urlencode(list(request.form.items()))
 | 
				
			||||||
 | 
					        return redirect(inherit_next(with_type(
 | 
				
			||||||
 | 
					            url_for("accounting.journal-entry.create",
 | 
				
			||||||
 | 
					                    journal_entry_type=journal_entry_type))))
 | 
				
			||||||
 | 
					    journal_entry: JournalEntry = JournalEntry()
 | 
				
			||||||
 | 
					    form.populate_obj(journal_entry)
 | 
				
			||||||
 | 
					    db.session.add(journal_entry)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(s(lazy_gettext("The journal entry is added successfully.")),
 | 
				
			||||||
 | 
					          "success")
 | 
				
			||||||
 | 
					    return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
 | 
				
			||||||
 | 
					    """Shows the journal entry detail.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					    :return: The detail.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    journal_entry_op: JournalEntryOperator \
 | 
				
			||||||
 | 
					        = get_journal_entry_op(journal_entry)
 | 
				
			||||||
 | 
					    return journal_entry_op.render_detail_template(journal_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
 | 
				
			||||||
 | 
					    """Shows the form to edit a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					    :return: The form to edit the journal entry.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    journal_entry_op: JournalEntryOperator \
 | 
				
			||||||
 | 
					        = get_journal_entry_op(journal_entry, is_check_as=True)
 | 
				
			||||||
 | 
					    form: journal_entry_op.form
 | 
				
			||||||
 | 
					    if "form" in session:
 | 
				
			||||||
 | 
					        form = journal_entry_op.form(
 | 
				
			||||||
 | 
					            ImmutableMultiDict(parse_qsl(session["form"])))
 | 
				
			||||||
 | 
					        del session["form"]
 | 
				
			||||||
 | 
					        form.obj = journal_entry
 | 
				
			||||||
 | 
					        form.validate()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        form = journal_entry_op.form(obj=journal_entry)
 | 
				
			||||||
 | 
					    return journal_entry_op.render_edit_template(journal_entry, form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def update_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
				
			||||||
 | 
					    """Updates a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					    :return: The redirection to the journal entry detail on success, or the
 | 
				
			||||||
 | 
					        journal entry edit form on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    journal_entry_op: JournalEntryOperator \
 | 
				
			||||||
 | 
					        = get_journal_entry_op(journal_entry, is_check_as=True)
 | 
				
			||||||
 | 
					    form: journal_entry_op.form = journal_entry_op.form(request.form)
 | 
				
			||||||
 | 
					    form.obj = journal_entry
 | 
				
			||||||
 | 
					    if not form.validate():
 | 
				
			||||||
 | 
					        flash_form_errors(form)
 | 
				
			||||||
 | 
					        session["form"] = urlencode(list(request.form.items()))
 | 
				
			||||||
 | 
					        return redirect(inherit_next(with_type(
 | 
				
			||||||
 | 
					            url_for("accounting.journal-entry.edit",
 | 
				
			||||||
 | 
					                    journal_entry=journal_entry))))
 | 
				
			||||||
 | 
					    with db.session.no_autoflush:
 | 
				
			||||||
 | 
					        form.populate_obj(journal_entry)
 | 
				
			||||||
 | 
					    if not form.is_modified:
 | 
				
			||||||
 | 
					        flash(s(lazy_gettext("The journal entry was not modified.")),
 | 
				
			||||||
 | 
					              "success")
 | 
				
			||||||
 | 
					        return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
				
			||||||
 | 
					    journal_entry.updated_by_id = get_current_user_pk()
 | 
				
			||||||
 | 
					    journal_entry.updated_at = sa.func.now()
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(s(lazy_gettext("The journal entry is updated successfully.")),
 | 
				
			||||||
 | 
					          "success")
 | 
				
			||||||
 | 
					    return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
				
			||||||
 | 
					    """Deletes a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					    :return: The redirection to the journal entry list on success, or the
 | 
				
			||||||
 | 
					        journal entry detail on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if not journal_entry.can_delete:
 | 
				
			||||||
 | 
					        flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error")
 | 
				
			||||||
 | 
					        return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
				
			||||||
 | 
					    journal_entry.delete()
 | 
				
			||||||
 | 
					    sort_journal_entries_in(journal_entry.date, journal_entry.id)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(s(lazy_gettext("The journal entry is deleted successfully.")),
 | 
				
			||||||
 | 
					          "success")
 | 
				
			||||||
 | 
					    return redirect(or_next(__get_default_page_uri()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def show_journal_entry_order(journal_entry_date: date) -> str:
 | 
				
			||||||
 | 
					    """Shows the order of the journal entries in a same date.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry_date: The date.
 | 
				
			||||||
 | 
					    :return: The order of the journal entries in the date.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    journal_entries: list[JournalEntry] = JournalEntry.query \
 | 
				
			||||||
 | 
					        .filter(JournalEntry.date == journal_entry_date) \
 | 
				
			||||||
 | 
					        .order_by(JournalEntry.no).all()
 | 
				
			||||||
 | 
					    return render_template("accounting/journal-entry/order.html",
 | 
				
			||||||
 | 
					                           date=journal_entry_date, list=journal_entries)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def sort_journal_entries(journal_entry_date: date) -> redirect:
 | 
				
			||||||
 | 
					    """Reorders the journal entries in a date.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry_date: The date.
 | 
				
			||||||
 | 
					    :return: The redirection to the incoming account or the account list.  The
 | 
				
			||||||
 | 
					        reordering operation does not fail.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
 | 
				
			||||||
 | 
					    form.save_order()
 | 
				
			||||||
 | 
					    if not form.is_modified:
 | 
				
			||||||
 | 
					        flash(s(lazy_gettext("The order was not modified.")), "success")
 | 
				
			||||||
 | 
					        return redirect(or_next(__get_default_page_uri()))
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(s(lazy_gettext("The order is updated successfully.")), "success")
 | 
				
			||||||
 | 
					    return redirect(or_next(__get_default_page_uri()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def __get_detail_uri(journal_entry: JournalEntry) -> str:
 | 
				
			||||||
 | 
					    """Returns the detail URI of a journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param journal_entry: The journal entry.
 | 
				
			||||||
 | 
					    :return: The detail URI of the journal entry.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return url_for("accounting.journal-entry.detail",
 | 
				
			||||||
 | 
					                   journal_entry=journal_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def __get_default_page_uri() -> str:
 | 
				
			||||||
 | 
					    """Returns the URI for the default page.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The URI for the default page.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return url_for("accounting.report.default")
 | 
				
			||||||
@@ -21,11 +21,12 @@ from __future__ import annotations
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
import typing as t
 | 
					import typing as t
 | 
				
			||||||
 | 
					from datetime import date
 | 
				
			||||||
from decimal import Decimal
 | 
					from decimal import Decimal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import sqlalchemy as sa
 | 
					import sqlalchemy as sa
 | 
				
			||||||
from flask import current_app
 | 
					from babel import Locale
 | 
				
			||||||
from flask_babel import get_locale
 | 
					from flask_babel import get_locale, get_babel
 | 
				
			||||||
from sqlalchemy import text
 | 
					from sqlalchemy import text
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
@@ -52,7 +53,7 @@ class BaseAccount(db.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The string representation of the base account.
 | 
					        :return: The string representation of the base account.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return f"{self.code} {self.title}"
 | 
					        return f"{self.code} {self.title.title()}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def title(self) -> str:
 | 
					    def title(self) -> str:
 | 
				
			||||||
@@ -60,11 +61,11 @@ class BaseAccount(db.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The title in the current locale.
 | 
					        :return: The title in the current locale.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        current_locale = str(get_locale())
 | 
					        current_locale: Locale = get_locale()
 | 
				
			||||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
					        if current_locale == get_babel().instance.default_locale:
 | 
				
			||||||
            return self.title_l10n
 | 
					            return self.title_l10n
 | 
				
			||||||
        for l10n in self.l10n:
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
            if l10n.locale == current_locale:
 | 
					            if l10n.locale == str(current_locale):
 | 
				
			||||||
                return l10n.title
 | 
					                return l10n.title
 | 
				
			||||||
        return self.title_l10n
 | 
					        return self.title_l10n
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -113,8 +114,8 @@ class Account(db.Model):
 | 
				
			|||||||
    """The account number under the base account."""
 | 
					    """The account number under the base account."""
 | 
				
			||||||
    title_l10n = db.Column("title", db.String, nullable=False)
 | 
					    title_l10n = db.Column("title", db.String, nullable=False)
 | 
				
			||||||
    """The title."""
 | 
					    """The title."""
 | 
				
			||||||
    is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
 | 
					    is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
 | 
				
			||||||
    """Whether the entries of this account need offset."""
 | 
					    """Whether the journal entry line items of this account need offset."""
 | 
				
			||||||
    created_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
					    created_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
				
			||||||
                           server_default=db.func.now())
 | 
					                           server_default=db.func.now())
 | 
				
			||||||
    """The time of creation."""
 | 
					    """The time of creation."""
 | 
				
			||||||
@@ -138,8 +139,9 @@ class Account(db.Model):
 | 
				
			|||||||
    l10n = db.relationship("AccountL10n", back_populates="account",
 | 
					    l10n = db.relationship("AccountL10n", back_populates="account",
 | 
				
			||||||
                           lazy=False)
 | 
					                           lazy=False)
 | 
				
			||||||
    """The localized titles."""
 | 
					    """The localized titles."""
 | 
				
			||||||
    entries = db.relationship("JournalEntry", back_populates="account")
 | 
					    line_items = db.relationship("JournalEntryLineItem",
 | 
				
			||||||
    """The journal entries."""
 | 
					                                 back_populates="account")
 | 
				
			||||||
 | 
					    """The journal entry line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    CASH_CODE: str = "1111-001"
 | 
					    CASH_CODE: str = "1111-001"
 | 
				
			||||||
    """The code of the cash account,"""
 | 
					    """The code of the cash account,"""
 | 
				
			||||||
@@ -153,7 +155,7 @@ class Account(db.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The string representation of this account.
 | 
					        :return: The string representation of this account.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return f"{self.base_code}-{self.no:03d} {self.title}"
 | 
					        return f"{self.base_code}-{self.no:03d} {self.title.title()}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def code(self) -> str:
 | 
					    def code(self) -> str:
 | 
				
			||||||
@@ -169,11 +171,11 @@ class Account(db.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The title in the current locale.
 | 
					        :return: The title in the current locale.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        current_locale = str(get_locale())
 | 
					        current_locale: Locale = get_locale()
 | 
				
			||||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
					        if current_locale == get_babel().instance.default_locale:
 | 
				
			||||||
            return self.title_l10n
 | 
					            return self.title_l10n
 | 
				
			||||||
        for l10n in self.l10n:
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
            if l10n.locale == current_locale:
 | 
					            if l10n.locale == str(current_locale):
 | 
				
			||||||
                return l10n.title
 | 
					                return l10n.title
 | 
				
			||||||
        return self.title_l10n
 | 
					        return self.title_l10n
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -187,15 +189,71 @@ class Account(db.Model):
 | 
				
			|||||||
        if self.title_l10n is None:
 | 
					        if self.title_l10n is None:
 | 
				
			||||||
            self.title_l10n = value
 | 
					            self.title_l10n = value
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        current_locale = str(get_locale())
 | 
					        current_locale: Locale = get_locale()
 | 
				
			||||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
					        if current_locale == get_babel().instance.default_locale:
 | 
				
			||||||
            self.title_l10n = value
 | 
					            self.title_l10n = value
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        for l10n in self.l10n:
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
            if l10n.locale == current_locale:
 | 
					            if l10n.locale == str(current_locale):
 | 
				
			||||||
                l10n.title = value
 | 
					                l10n.title = value
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
        self.l10n.append(AccountL10n(locale=current_locale, title=value))
 | 
					        self.l10n.append(AccountL10n(locale=str(current_locale), title=value))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_real(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the account is a real account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the account is a real account, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return self.base_code[0] in {"1", "2", "3"}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_nominal(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the account is a nominal account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the account is a nominal account, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return not self.is_real
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def query_values(self) -> list[str]:
 | 
				
			||||||
 | 
					        """Returns the values to be queried.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The values to be queried.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [self.code, self.title_l10n] + [x.title for x in self.l10n]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_modified(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether a product account was modified.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if modified, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if db.session.is_modified(self):
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if db.session.is_modified(l10n):
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def can_delete(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the account can be deleted.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the account can be deleted, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if self.code in {"1111-001", "3351-001", "3353-001"}:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return len(self.line_items) == 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete(self) -> None:
 | 
				
			||||||
 | 
					        """Deletes this account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        AccountL10n.query.filter(AccountL10n.account == self).delete()
 | 
				
			||||||
 | 
					        cls: t.Type[t.Self] = self.__class__
 | 
				
			||||||
 | 
					        cls.query.filter(cls.id == self.id).delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def find_by_code(cls, code: str) -> t.Self | None:
 | 
					    def find_by_code(cls, code: str) -> t.Self | None:
 | 
				
			||||||
@@ -251,14 +309,6 @@ class Account(db.Model):
 | 
				
			|||||||
                                cls.base_code != "3353")\
 | 
					                                cls.base_code != "3353")\
 | 
				
			||||||
            .order_by(cls.base_code, cls.no).all()
 | 
					            .order_by(cls.base_code, cls.no).all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def query_values(self) -> list[str]:
 | 
					 | 
				
			||||||
        """Returns the values to be queried.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        :return: The values to be queried.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        return [self.code, self.title_l10n] + [x.title for x in self.l10n]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def cash(cls) -> t.Self:
 | 
					    def cash(cls) -> t.Self:
 | 
				
			||||||
        """Returns the cash account.
 | 
					        """Returns the cash account.
 | 
				
			||||||
@@ -275,28 +325,6 @@ class Account(db.Model):
 | 
				
			|||||||
        """
 | 
					        """
 | 
				
			||||||
        return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
 | 
					        return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def is_modified(self) -> bool:
 | 
					 | 
				
			||||||
        """Returns whether a product account was modified.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        :return: True if modified, or False otherwise.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        if db.session.is_modified(self):
 | 
					 | 
				
			||||||
            return True
 | 
					 | 
				
			||||||
        for l10n in self.l10n:
 | 
					 | 
				
			||||||
            if db.session.is_modified(l10n):
 | 
					 | 
				
			||||||
                return True
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def delete(self) -> None:
 | 
					 | 
				
			||||||
        """Deletes this account.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        :return: None.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        AccountL10n.query.filter(AccountL10n.account == self).delete()
 | 
					 | 
				
			||||||
        cls: t.Type[t.Self] = self.__class__
 | 
					 | 
				
			||||||
        cls.query.filter(cls.id == self.id).delete()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountL10n(db.Model):
 | 
					class AccountL10n(db.Model):
 | 
				
			||||||
    """A localized account title."""
 | 
					    """A localized account title."""
 | 
				
			||||||
@@ -346,15 +374,16 @@ class Currency(db.Model):
 | 
				
			|||||||
    l10n = db.relationship("CurrencyL10n", back_populates="currency",
 | 
					    l10n = db.relationship("CurrencyL10n", back_populates="currency",
 | 
				
			||||||
                           lazy=False)
 | 
					                           lazy=False)
 | 
				
			||||||
    """The localized names."""
 | 
					    """The localized names."""
 | 
				
			||||||
    entries = db.relationship("JournalEntry", back_populates="currency")
 | 
					    line_items = db.relationship("JournalEntryLineItem",
 | 
				
			||||||
    """The journal entries."""
 | 
					                                 back_populates="currency")
 | 
				
			||||||
 | 
					    """The journal entry line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self) -> str:
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
        """Returns the string representation of the currency.
 | 
					        """Returns the string representation of the currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The string representation of the currency.
 | 
					        :return: The string representation of the currency.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return f"{self.name} ({self.code})"
 | 
					        return f"{self.name.title()} ({self.code})"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def name(self) -> str:
 | 
					    def name(self) -> str:
 | 
				
			||||||
@@ -362,11 +391,11 @@ class Currency(db.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The name in the current locale.
 | 
					        :return: The name in the current locale.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        current_locale = str(get_locale())
 | 
					        current_locale: Locale = get_locale()
 | 
				
			||||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
					        if current_locale == get_babel().instance.default_locale:
 | 
				
			||||||
            return self.name_l10n
 | 
					            return self.name_l10n
 | 
				
			||||||
        for l10n in self.l10n:
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
            if l10n.locale == current_locale:
 | 
					            if l10n.locale == str(current_locale):
 | 
				
			||||||
                return l10n.name
 | 
					                return l10n.name
 | 
				
			||||||
        return self.name_l10n
 | 
					        return self.name_l10n
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -380,15 +409,15 @@ class Currency(db.Model):
 | 
				
			|||||||
        if self.name_l10n is None:
 | 
					        if self.name_l10n is None:
 | 
				
			||||||
            self.name_l10n = value
 | 
					            self.name_l10n = value
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        current_locale = str(get_locale())
 | 
					        current_locale: Locale = get_locale()
 | 
				
			||||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
					        if current_locale == get_babel().instance.default_locale:
 | 
				
			||||||
            self.name_l10n = value
 | 
					            self.name_l10n = value
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        for l10n in self.l10n:
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
            if l10n.locale == current_locale:
 | 
					            if l10n.locale == str(current_locale):
 | 
				
			||||||
                l10n.name = value
 | 
					                l10n.name = value
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
        self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
 | 
					        self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def is_modified(self) -> bool:
 | 
					    def is_modified(self) -> bool:
 | 
				
			||||||
@@ -403,6 +432,17 @@ class Currency(db.Model):
 | 
				
			|||||||
                return True
 | 
					                return True
 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def can_delete(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the currency can be deleted.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the currency can be deleted, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.template_globals import default_currency_code
 | 
				
			||||||
 | 
					        if self.code == default_currency_code():
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return len(self.line_items) == 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def delete(self) -> None:
 | 
					    def delete(self) -> None:
 | 
				
			||||||
        """Deletes the currency.
 | 
					        """Deletes the currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -430,23 +470,23 @@ class CurrencyL10n(db.Model):
 | 
				
			|||||||
    """The localized name."""
 | 
					    """The localized name."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TransactionCurrency:
 | 
					class JournalEntryCurrency:
 | 
				
			||||||
    """A currency in a transaction."""
 | 
					    """A currency in a journal entry."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, code: str, debit: list[JournalEntry],
 | 
					    def __init__(self, code: str, debit: list[JournalEntryLineItem],
 | 
				
			||||||
                 credit: list[JournalEntry]):
 | 
					                 credit: list[JournalEntryLineItem]):
 | 
				
			||||||
        """Constructs the currency in the transaction.
 | 
					        """Constructs the currency in the journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param code: The currency code.
 | 
					        :param code: The currency code.
 | 
				
			||||||
        :param debit: The debit entries.
 | 
					        :param debit: The debit line items.
 | 
				
			||||||
        :param credit: The credit entries.
 | 
					        :param credit: The credit line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.code: str = code
 | 
					        self.code: str = code
 | 
				
			||||||
        """The currency code."""
 | 
					        """The currency code."""
 | 
				
			||||||
        self.debit: list[JournalEntry] = debit
 | 
					        self.debit: list[JournalEntryLineItem] = debit
 | 
				
			||||||
        """The debit entries."""
 | 
					        """The debit line items."""
 | 
				
			||||||
        self.credit: list[JournalEntry] = credit
 | 
					        self.credit: list[JournalEntryLineItem] = credit
 | 
				
			||||||
        """The credit entries."""
 | 
					        """The credit line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def name(self) -> str:
 | 
					    def name(self) -> str:
 | 
				
			||||||
@@ -458,28 +498,28 @@ class TransactionCurrency:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def debit_total(self) -> Decimal:
 | 
					    def debit_total(self) -> Decimal:
 | 
				
			||||||
        """Returns the total amount of the debit journal entries.
 | 
					        """Returns the total amount of the debit line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The total amount of the debit journal entries.
 | 
					        :return: The total amount of the debit line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return sum([x.amount for x in self.debit])
 | 
					        return sum([x.amount for x in self.debit])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def credit_total(self) -> str:
 | 
					    def credit_total(self) -> str:
 | 
				
			||||||
        """Returns the total amount of the credit journal entries.
 | 
					        """Returns the total amount of the credit line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The total amount of the credit journal entries.
 | 
					        :return: The total amount of the credit line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return sum([x.amount for x in self.credit])
 | 
					        return sum([x.amount for x in self.credit])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Transaction(db.Model):
 | 
					class JournalEntry(db.Model):
 | 
				
			||||||
    """A transaction."""
 | 
					    """A journal entry."""
 | 
				
			||||||
    __tablename__ = "accounting_transactions"
 | 
					    __tablename__ = "accounting_journal_entries"
 | 
				
			||||||
    """The table name."""
 | 
					    """The table name."""
 | 
				
			||||||
    id = db.Column(db.Integer, nullable=False, primary_key=True,
 | 
					    id = db.Column(db.Integer, nullable=False, primary_key=True,
 | 
				
			||||||
                   autoincrement=False)
 | 
					                   autoincrement=False)
 | 
				
			||||||
    """The transaction ID."""
 | 
					    """The journal entry ID."""
 | 
				
			||||||
    date = db.Column(db.Date, nullable=False)
 | 
					    date = db.Column(db.Date, nullable=False)
 | 
				
			||||||
    """The date."""
 | 
					    """The date."""
 | 
				
			||||||
    no = db.Column(db.Integer, nullable=False, default=text("1"))
 | 
					    no = db.Column(db.Integer, nullable=False, default=text("1"))
 | 
				
			||||||
@@ -506,46 +546,50 @@ class Transaction(db.Model):
 | 
				
			|||||||
    """The ID of the updator."""
 | 
					    """The ID of the updator."""
 | 
				
			||||||
    updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
 | 
					    updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
 | 
				
			||||||
    """The updator."""
 | 
					    """The updator."""
 | 
				
			||||||
    entries = db.relationship("JournalEntry", back_populates="transaction")
 | 
					    line_items = db.relationship("JournalEntryLineItem",
 | 
				
			||||||
    """The journal entries."""
 | 
					                                 back_populates="journal_entry")
 | 
				
			||||||
 | 
					    """The line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self) -> str:
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
        """Returns the string representation of this transaction.
 | 
					        """Returns the string representation of this journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The string representation of this transaction.
 | 
					        :return: The string representation of this journal entry.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if self.is_cash_expense:
 | 
					        if self.is_cash_disbursement:
 | 
				
			||||||
            return gettext("Cash Expense Transaction#%(id)s", id=self.id)
 | 
					            return gettext("Cash Disbursement Journal Entry#%(id)s",
 | 
				
			||||||
        if self.is_cash_income:
 | 
					                           id=self.id)
 | 
				
			||||||
            return gettext("Cash Income Transaction#%(id)s", id=self.id)
 | 
					        if self.is_cash_receipt:
 | 
				
			||||||
        return gettext("Transfer Transaction#%(id)s", id=self.id)
 | 
					            return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id)
 | 
				
			||||||
 | 
					        return gettext("Transfer Journal Entry#%(id)s", id=self.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def currencies(self) -> list[TransactionCurrency]:
 | 
					    def currencies(self) -> list[JournalEntryCurrency]:
 | 
				
			||||||
        """Returns the journal entries categorized by their currencies.
 | 
					        """Returns the line items categorized by their currencies.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The currency categories.
 | 
					        :return: The currency categories.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
 | 
					        line_items: list[JournalEntryLineItem] = sorted(self.line_items,
 | 
				
			||||||
 | 
					                                                        key=lambda x: x.no)
 | 
				
			||||||
        codes: list[str] = []
 | 
					        codes: list[str] = []
 | 
				
			||||||
        by_currency: dict[str, list[JournalEntry]] = {}
 | 
					        by_currency: dict[str, list[JournalEntryLineItem]] = {}
 | 
				
			||||||
        for entry in entries:
 | 
					        for line_item in line_items:
 | 
				
			||||||
            if entry.currency_code not in by_currency:
 | 
					            if line_item.currency_code not in by_currency:
 | 
				
			||||||
                codes.append(entry.currency_code)
 | 
					                codes.append(line_item.currency_code)
 | 
				
			||||||
                by_currency[entry.currency_code] = []
 | 
					                by_currency[line_item.currency_code] = []
 | 
				
			||||||
            by_currency[entry.currency_code].append(entry)
 | 
					            by_currency[line_item.currency_code].append(line_item)
 | 
				
			||||||
        return [TransactionCurrency(code=x,
 | 
					        return [JournalEntryCurrency(code=x,
 | 
				
			||||||
                                    debit=[y for y in by_currency[x]
 | 
					                                     debit=[y for y in by_currency[x]
 | 
				
			||||||
                                           if y.is_debit],
 | 
					                                            if y.is_debit],
 | 
				
			||||||
                                    credit=[y for y in by_currency[x]
 | 
					                                     credit=[y for y in by_currency[x]
 | 
				
			||||||
                                            if not y.is_debit])
 | 
					                                             if not y.is_debit])
 | 
				
			||||||
                for x in codes]
 | 
					                for x in codes]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def is_cash_income(self) -> bool:
 | 
					    def is_cash_receipt(self) -> bool:
 | 
				
			||||||
        """Returns whether this is a cash income transaction.
 | 
					        """Returns whether this is a cash receipt journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: True if this is a cash income transaction, or False otherwise.
 | 
					        :return: True if this is a cash receipt journal entry, or False
 | 
				
			||||||
 | 
					            otherwise.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        for currency in self.currencies:
 | 
					        for currency in self.currencies:
 | 
				
			||||||
            if len(currency.debit) > 1:
 | 
					            if len(currency.debit) > 1:
 | 
				
			||||||
@@ -555,10 +599,10 @@ class Transaction(db.Model):
 | 
				
			|||||||
        return True
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def is_cash_expense(self) -> bool:
 | 
					    def is_cash_disbursement(self) -> bool:
 | 
				
			||||||
        """Returns whether this is a cash expense transaction.
 | 
					        """Returns whether this is a cash disbursement journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: True if this is a cash expense transaction, or False
 | 
					        :return: True if this is a cash disbursement journal entry, or False
 | 
				
			||||||
            otherwise.
 | 
					            otherwise.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        for currency in self.currencies:
 | 
					        for currency in self.currencies:
 | 
				
			||||||
@@ -568,70 +612,89 @@ class Transaction(db.Model):
 | 
				
			|||||||
                return False
 | 
					                return False
 | 
				
			||||||
        return True
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def can_delete(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the journal entry can be deleted.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the journal entry can be deleted, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        for line_item in self.line_items:
 | 
				
			||||||
 | 
					            if len(line_item.offsets) > 0:
 | 
				
			||||||
 | 
					                return False
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def delete(self) -> None:
 | 
					    def delete(self) -> None:
 | 
				
			||||||
        """Deletes the transaction.
 | 
					        """Deletes the journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        JournalEntry.query\
 | 
					        JournalEntryLineItem.query\
 | 
				
			||||||
            .filter(JournalEntry.transaction_id == self.id).delete()
 | 
					            .filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
 | 
				
			||||||
        db.session.delete(self)
 | 
					        db.session.delete(self)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class JournalEntry(db.Model):
 | 
					class JournalEntryLineItem(db.Model):
 | 
				
			||||||
    """An accounting journal entry."""
 | 
					    """A line item in the journal entry."""
 | 
				
			||||||
    __tablename__ = "accounting_journal_entries"
 | 
					    __tablename__ = "accounting_journal_entry_line_items"
 | 
				
			||||||
    """The table name."""
 | 
					    """The table name."""
 | 
				
			||||||
    id = db.Column(db.Integer, nullable=False, primary_key=True,
 | 
					    id = db.Column(db.Integer, nullable=False, primary_key=True,
 | 
				
			||||||
                   autoincrement=False)
 | 
					                   autoincrement=False)
 | 
				
			||||||
    """The entry ID."""
 | 
					    """The line item ID."""
 | 
				
			||||||
    transaction_id = db.Column(db.Integer,
 | 
					    journal_entry_id = db.Column(db.Integer,
 | 
				
			||||||
                               db.ForeignKey(Transaction.id,
 | 
					                                 db.ForeignKey(JournalEntry.id,
 | 
				
			||||||
                                             onupdate="CASCADE",
 | 
					                                               onupdate="CASCADE",
 | 
				
			||||||
                                             ondelete="CASCADE"),
 | 
					                                               ondelete="CASCADE"),
 | 
				
			||||||
                               nullable=False)
 | 
					                                 nullable=False)
 | 
				
			||||||
    """The transaction ID."""
 | 
					    """The journal entry ID."""
 | 
				
			||||||
    transaction = db.relationship(Transaction, back_populates="entries")
 | 
					    journal_entry = db.relationship(JournalEntry, back_populates="line_items")
 | 
				
			||||||
    """The transaction."""
 | 
					    """The journal entry."""
 | 
				
			||||||
    is_debit = db.Column(db.Boolean, nullable=False)
 | 
					    is_debit = db.Column(db.Boolean, nullable=False)
 | 
				
			||||||
    """True for a debit entry, or False for a credit entry."""
 | 
					    """True for a debit line item, or False for a credit line item."""
 | 
				
			||||||
    no = db.Column(db.Integer, nullable=False)
 | 
					    no = db.Column(db.Integer, nullable=False)
 | 
				
			||||||
    """The entry number under the transaction and debit or credit."""
 | 
					    """The line item number under the journal entry and debit or credit."""
 | 
				
			||||||
    offset_original_id = db.Column(db.Integer,
 | 
					    original_line_item_id = db.Column(db.Integer,
 | 
				
			||||||
                                   db.ForeignKey(id, onupdate="CASCADE"),
 | 
					                                      db.ForeignKey(id, onupdate="CASCADE"),
 | 
				
			||||||
                                   nullable=True)
 | 
					                                      nullable=True)
 | 
				
			||||||
    """The ID of the original entry to offset."""
 | 
					    """The ID of the original line item."""
 | 
				
			||||||
    offset_original = db.relationship("JournalEntry", back_populates="offsets",
 | 
					    original_line_item = db.relationship("JournalEntryLineItem",
 | 
				
			||||||
                                      remote_side=id, passive_deletes=True)
 | 
					                                         back_populates="offsets",
 | 
				
			||||||
    """The original entry to offset."""
 | 
					                                         remote_side=id, passive_deletes=True)
 | 
				
			||||||
    offsets = db.relationship("JournalEntry", back_populates="offset_original")
 | 
					    """The original line item."""
 | 
				
			||||||
    """The offset entries."""
 | 
					    offsets = db.relationship("JournalEntryLineItem",
 | 
				
			||||||
 | 
					                              back_populates="original_line_item")
 | 
				
			||||||
 | 
					    """The offset items."""
 | 
				
			||||||
    currency_code = db.Column(db.String,
 | 
					    currency_code = db.Column(db.String,
 | 
				
			||||||
                              db.ForeignKey(Currency.code, onupdate="CASCADE"),
 | 
					                              db.ForeignKey(Currency.code, onupdate="CASCADE"),
 | 
				
			||||||
                              nullable=False)
 | 
					                              nullable=False)
 | 
				
			||||||
    """The currency code."""
 | 
					    """The currency code."""
 | 
				
			||||||
    currency = db.relationship(Currency, back_populates="entries")
 | 
					    currency = db.relationship(Currency, back_populates="line_items")
 | 
				
			||||||
    """The currency."""
 | 
					    """The currency."""
 | 
				
			||||||
    account_id = db.Column(db.Integer,
 | 
					    account_id = db.Column(db.Integer,
 | 
				
			||||||
                           db.ForeignKey(Account.id,
 | 
					                           db.ForeignKey(Account.id,
 | 
				
			||||||
                                         onupdate="CASCADE"),
 | 
					                                         onupdate="CASCADE"),
 | 
				
			||||||
                           nullable=False)
 | 
					                           nullable=False)
 | 
				
			||||||
    """The account ID."""
 | 
					    """The account ID."""
 | 
				
			||||||
    account = db.relationship(Account, back_populates="entries", lazy=False)
 | 
					    account = db.relationship(Account, back_populates="line_items", lazy=False)
 | 
				
			||||||
    """The account."""
 | 
					    """The account."""
 | 
				
			||||||
    summary = db.Column(db.String, nullable=True)
 | 
					    description = db.Column(db.String, nullable=True)
 | 
				
			||||||
    """The summary."""
 | 
					    """The description."""
 | 
				
			||||||
    amount = db.Column(db.Numeric(14, 2), nullable=False)
 | 
					    amount = db.Column(db.Numeric(14, 2), nullable=False)
 | 
				
			||||||
    """The amount."""
 | 
					    """The amount."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
    def eid(self) -> int | None:
 | 
					        """Returns the string representation of the line item.
 | 
				
			||||||
        """Returns the journal entry ID.  This is the alternative name of the
 | 
					 | 
				
			||||||
        ID field, to work with WTForms.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The journal entry ID.
 | 
					        :return: The string representation of the line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return self.id
 | 
					        if not hasattr(self, "__str"):
 | 
				
			||||||
 | 
					            from accounting.template_filters import format_date, format_amount
 | 
				
			||||||
 | 
					            setattr(self, "__str",
 | 
				
			||||||
 | 
					                    gettext("%(date)s %(description)s %(amount)s",
 | 
				
			||||||
 | 
					                            date=format_date(self.journal_entry.date),
 | 
				
			||||||
 | 
					                            description="" if self.description is None
 | 
				
			||||||
 | 
					                            else self.description,
 | 
				
			||||||
 | 
					                            amount=format_amount(self.amount)))
 | 
				
			||||||
 | 
					        return getattr(self, "__str")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def account_code(self) -> str:
 | 
					    def account_code(self) -> str:
 | 
				
			||||||
@@ -645,14 +708,74 @@ class JournalEntry(db.Model):
 | 
				
			|||||||
    def debit(self) -> Decimal | None:
 | 
					    def debit(self) -> Decimal | None:
 | 
				
			||||||
        """Returns the debit amount.
 | 
					        """Returns the debit amount.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The debit amount, or None if this is not a debit entry.
 | 
					        :return: The debit amount, or None if this is not a debit line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return self.amount if self.is_debit else None
 | 
					        return self.amount if self.is_debit else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_need_offset(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether the line item needs offset.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if the line item needs offset, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not self.account.is_need_offset:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        if self.account.base_code[0] == "1" and not self.is_debit:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        if self.account.base_code[0] == "2" and self.is_debit:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def credit(self) -> Decimal | None:
 | 
					    def credit(self) -> Decimal | None:
 | 
				
			||||||
        """Returns the credit amount.
 | 
					        """Returns the credit amount.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The credit amount, or None if this is not a credit entry.
 | 
					        :return: The credit amount, or None if this is not a credit line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return None if self.is_debit else self.amount
 | 
					        return None if self.is_debit else self.amount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def net_balance(self) -> Decimal:
 | 
				
			||||||
 | 
					        """Returns the net balance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The net balance.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not hasattr(self, "__net_balance"):
 | 
				
			||||||
 | 
					            setattr(self, "__net_balance", self.amount + sum(
 | 
				
			||||||
 | 
					                [x.amount if x.is_debit == self.is_debit else -x.amount
 | 
				
			||||||
 | 
					                 for x in self.offsets]))
 | 
				
			||||||
 | 
					        return getattr(self, "__net_balance")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @net_balance.setter
 | 
				
			||||||
 | 
					    def net_balance(self, net_balance: Decimal) -> None:
 | 
				
			||||||
 | 
					        """Sets the net balance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param net_balance: The net balance.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        setattr(self, "__net_balance", net_balance)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def query_values(self) -> tuple[list[str], list[str]]:
 | 
				
			||||||
 | 
					        """Returns the values to be queried.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The values to be queried.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        def format_amount(value: Decimal) -> str:
 | 
				
			||||||
 | 
					            whole: int = int(value)
 | 
				
			||||||
 | 
					            frac: Decimal = (value - whole).normalize()
 | 
				
			||||||
 | 
					            return str(whole) + str(abs(frac))[1:]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        journal_entry_day: date = self.journal_entry.date
 | 
				
			||||||
 | 
					        description: str = "" if self.description is None else self.description
 | 
				
			||||||
 | 
					        return ([description],
 | 
				
			||||||
 | 
					                [str(journal_entry_day.year),
 | 
				
			||||||
 | 
					                 "{}/{}".format(journal_entry_day.year,
 | 
				
			||||||
 | 
					                                journal_entry_day.month),
 | 
				
			||||||
 | 
					                 "{}/{}".format(journal_entry_day.month,
 | 
				
			||||||
 | 
					                                journal_entry_day.day),
 | 
				
			||||||
 | 
					                 "{}/{}/{}".format(journal_entry_day.year,
 | 
				
			||||||
 | 
					                                   journal_entry_day.month,
 | 
				
			||||||
 | 
					                                   journal_entry_day.day),
 | 
				
			||||||
 | 
					                 format_amount(self.amount),
 | 
				
			||||||
 | 
					                 format_amount(self.net_balance)])
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,7 +23,7 @@ This file is largely taken from the NanoParma ERP project, first written in
 | 
				
			|||||||
import typing as t
 | 
					import typing as t
 | 
				
			||||||
from datetime import date
 | 
					from datetime import date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounting.models import Transaction
 | 
					from accounting.models import JournalEntry
 | 
				
			||||||
from .period import Period
 | 
					from .period import Period
 | 
				
			||||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
 | 
					from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
 | 
				
			||||||
    LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
 | 
					    LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
 | 
				
			||||||
@@ -61,8 +61,8 @@ class PeriodChooser:
 | 
				
			|||||||
        self.url_template: str = get_url(TemplatePeriod())
 | 
					        self.url_template: str = get_url(TemplatePeriod())
 | 
				
			||||||
        """The URL template."""
 | 
					        """The URL template."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        first: Transaction | None \
 | 
					        first: JournalEntry | None \
 | 
				
			||||||
            = Transaction.query.order_by(Transaction.date).first()
 | 
					            = JournalEntry.query.order_by(JournalEntry.date).first()
 | 
				
			||||||
        start: date | None = None if first is None else first.date
 | 
					        start: date | None = None if first is None else first.date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Attributes
 | 
					        # Attributes
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,7 +35,7 @@ class ThisMonth(Period):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _set_properties(self) -> None:
 | 
					    def _set_properties(self) -> None:
 | 
				
			||||||
        self.spec = "this-month"
 | 
					        self.spec = "this-month"
 | 
				
			||||||
        self.desc = gettext("This month")
 | 
					        self.desc = gettext("This Month")
 | 
				
			||||||
        self.is_a_month = True
 | 
					        self.is_a_month = True
 | 
				
			||||||
        self.is_type_month = True
 | 
					        self.is_type_month = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -55,7 +55,7 @@ class LastMonth(Period):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _set_properties(self) -> None:
 | 
					    def _set_properties(self) -> None:
 | 
				
			||||||
        self.spec = "last-month"
 | 
					        self.spec = "last-month"
 | 
				
			||||||
        self.desc = gettext("Last month")
 | 
					        self.desc = gettext("Last Month")
 | 
				
			||||||
        self.is_a_month = True
 | 
					        self.is_a_month = True
 | 
				
			||||||
        self.is_type_month = True
 | 
					        self.is_type_month = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -75,7 +75,7 @@ class SinceLastMonth(Period):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _set_properties(self) -> None:
 | 
					    def _set_properties(self) -> None:
 | 
				
			||||||
        self.spec = "since-last-month"
 | 
					        self.spec = "since-last-month"
 | 
				
			||||||
        self.desc = gettext("Since last month")
 | 
					        self.desc = gettext("Since Last Month")
 | 
				
			||||||
        self.is_type_month = True
 | 
					        self.is_type_month = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -90,7 +90,7 @@ class ThisYear(Period):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _set_properties(self) -> None:
 | 
					    def _set_properties(self) -> None:
 | 
				
			||||||
        self.spec = "this-year"
 | 
					        self.spec = "this-year"
 | 
				
			||||||
        self.desc = gettext("This year")
 | 
					        self.desc = gettext("This Year")
 | 
				
			||||||
        self.is_a_year = True
 | 
					        self.is_a_year = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -105,7 +105,7 @@ class LastYear(Period):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _set_properties(self) -> None:
 | 
					    def _set_properties(self) -> None:
 | 
				
			||||||
        self.spec = "last-year"
 | 
					        self.spec = "last-year"
 | 
				
			||||||
        self.desc = gettext("Last year")
 | 
					        self.desc = gettext("Last Year")
 | 
				
			||||||
        self.is_a_year = True
 | 
					        self.is_a_year = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,8 +24,8 @@ from flask import render_template, Response
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext
 | 
				
			||||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
 | 
					from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
 | 
				
			||||||
    JournalEntry
 | 
					    JournalEntryLineItem
 | 
				
			||||||
from accounting.report.period import Period, PeriodChooser
 | 
					from accounting.report.period import Period, PeriodChooser
 | 
				
			||||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
					from accounting.report.utils.base_page_params import BasePageParams
 | 
				
			||||||
from accounting.report.utils.base_report import BaseReport
 | 
					from accounting.report.utils.base_report import BaseReport
 | 
				
			||||||
@@ -124,17 +124,17 @@ class AccountCollector:
 | 
				
			|||||||
        sub_conditions: list[sa.BinaryExpression] \
 | 
					        sub_conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
 | 
					            = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
					            = [JournalEntryLineItem.currency_code == self.__currency.code,
 | 
				
			||||||
               sa.or_(*sub_conditions)]
 | 
					               sa.or_(*sub_conditions)]
 | 
				
			||||||
        if self.__period.end is not None:
 | 
					        if self.__period.end is not None:
 | 
				
			||||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
					            conditions.append(JournalEntry.date <= self.__period.end)
 | 
				
			||||||
        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
					        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
				
			||||||
            (JournalEntry.is_debit, JournalEntry.amount),
 | 
					            (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
 | 
				
			||||||
            else_=-JournalEntry.amount)).label("balance")
 | 
					            else_=-JournalEntryLineItem.amount)).label("balance")
 | 
				
			||||||
        select_balance: sa.Select \
 | 
					        select_balance: sa.Select \
 | 
				
			||||||
            = sa.select(Account.id, Account.base_code, Account.no,
 | 
					            = sa.select(Account.id, Account.base_code, Account.no,
 | 
				
			||||||
                        balance_func)\
 | 
					                        balance_func)\
 | 
				
			||||||
            .join(Transaction).join(Account)\
 | 
					            .join(JournalEntry).join(Account)\
 | 
				
			||||||
            .filter(*conditions)\
 | 
					            .filter(*conditions)\
 | 
				
			||||||
            .group_by(Account.id, Account.base_code, Account.no)\
 | 
					            .group_by(Account.id, Account.base_code, Account.no)\
 | 
				
			||||||
            .order_by(Account.base_code, Account.no)
 | 
					            .order_by(Account.base_code, Account.no)
 | 
				
			||||||
@@ -178,8 +178,8 @@ class AccountCollector:
 | 
				
			|||||||
        if self.__period.start is None:
 | 
					        if self.__period.start is None:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
					            = [JournalEntryLineItem.currency_code == self.__currency.code,
 | 
				
			||||||
               Transaction.date < self.__period.start]
 | 
					               JournalEntry.date < self.__period.start]
 | 
				
			||||||
        return self.__query_balance(conditions)
 | 
					        return self.__query_balance(conditions)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __add_current_period(self) -> None:
 | 
					    def __add_current_period(self) -> None:
 | 
				
			||||||
@@ -188,20 +188,20 @@ class AccountCollector:
 | 
				
			|||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
 | 
					        self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
 | 
				
			||||||
                                  self.__query_currency_period(),
 | 
					                                  self.__query_current_period(),
 | 
				
			||||||
                                  self.__period)
 | 
					                                  self.__period)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __query_currency_period(self) -> Decimal | None:
 | 
					    def __query_current_period(self) -> Decimal | None:
 | 
				
			||||||
        """Queries and returns the net income or loss for current period.
 | 
					        """Queries and returns the net income or loss for current period.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The net income or loss for current period.
 | 
					        :return: The net income or loss for current period.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [JournalEntry.currency_code == self.__currency.code]
 | 
					            = [JournalEntryLineItem.currency_code == self.__currency.code]
 | 
				
			||||||
        if self.__period.start is not None:
 | 
					        if self.__period.start is not None:
 | 
				
			||||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
					            conditions.append(JournalEntry.date >= self.__period.start)
 | 
				
			||||||
        if self.__period.end is not None:
 | 
					        if self.__period.end is not None:
 | 
				
			||||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
					            conditions.append(JournalEntry.date <= self.__period.end)
 | 
				
			||||||
        return self.__query_balance(conditions)
 | 
					        return self.__query_balance(conditions)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
@@ -213,12 +213,12 @@ class AccountCollector:
 | 
				
			|||||||
        :return: The balance.
 | 
					        :return: The balance.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        conditions.extend([sa.not_(Account.base_code.startswith(x))
 | 
					        conditions.extend([sa.not_(Account.base_code.startswith(x))
 | 
				
			||||||
                           for x in {"1", "2"}])
 | 
					                           for x in {"1", "2", "3"}])
 | 
				
			||||||
        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
					        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
				
			||||||
            (JournalEntry.is_debit, JournalEntry.amount),
 | 
					            (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
 | 
				
			||||||
            else_=-JournalEntry.amount))
 | 
					            else_=-JournalEntryLineItem.amount))
 | 
				
			||||||
        select_balance: sa.Select = sa.select(balance_func)\
 | 
					        select_balance: sa.Select = sa.select(balance_func)\
 | 
				
			||||||
            .join(Transaction).join(Account).filter(*conditions)
 | 
					            .join(JournalEntry).join(Account).filter(*conditions)
 | 
				
			||||||
        return db.session.scalar(select_balance)
 | 
					        return db.session.scalar(select_balance)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __add_owner_s_equity(self, code: str, amount: Decimal | None,
 | 
					    def __add_owner_s_equity(self, code: str, amount: Decimal | None,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext
 | 
				
			||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
 | 
					from accounting.models import Currency, Account, JournalEntry, \
 | 
				
			||||||
 | 
					    JournalEntryLineItem
 | 
				
			||||||
from accounting.report.period import Period, PeriodChooser
 | 
					from accounting.report.period import Period, PeriodChooser
 | 
				
			||||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
					from accounting.report.utils.base_page_params import BasePageParams
 | 
				
			||||||
from accounting.report.utils.base_report import BaseReport
 | 
					from accounting.report.utils.base_report import BaseReport
 | 
				
			||||||
@@ -37,27 +38,28 @@ from accounting.report.utils.option_link import OptionLink
 | 
				
			|||||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
					from accounting.report.utils.report_chooser import ReportChooser
 | 
				
			||||||
from accounting.report.utils.report_type import ReportType
 | 
					from accounting.report.utils.report_type import ReportType
 | 
				
			||||||
from accounting.report.utils.urls import income_expenses_url
 | 
					from accounting.report.utils.urls import income_expenses_url
 | 
				
			||||||
 | 
					from accounting.utils.cast import be
 | 
				
			||||||
from accounting.utils.pagination import Pagination
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ReportEntry:
 | 
					class ReportLineItem:
 | 
				
			||||||
    """An entry in the report."""
 | 
					    """A line item in the report."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, entry: JournalEntry | None = None):
 | 
					    def __init__(self, line_item: JournalEntryLineItem | None = None):
 | 
				
			||||||
        """Constructs the entry in the report.
 | 
					        """Constructs the line item in the report.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param entry: The journal entry.
 | 
					        :param line_item: The journal entry line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.is_brought_forward: bool = False
 | 
					        self.is_brought_forward: bool = False
 | 
				
			||||||
        """Whether this is the brought-forward entry."""
 | 
					        """Whether this is the brought-forward line item."""
 | 
				
			||||||
        self.is_total: bool = False
 | 
					        self.is_total: bool = False
 | 
				
			||||||
        """Whether this is the total entry."""
 | 
					        """Whether this is the total line item."""
 | 
				
			||||||
        self.date: date | None = None
 | 
					        self.date: date | None = None
 | 
				
			||||||
        """The date."""
 | 
					        """The date."""
 | 
				
			||||||
        self.account: Account | None = None
 | 
					        self.account: Account | None = None
 | 
				
			||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.summary: str | None = None
 | 
					        self.description: str | None = None
 | 
				
			||||||
        """The summary."""
 | 
					        """The description."""
 | 
				
			||||||
        self.income: Decimal | None = None
 | 
					        self.income: Decimal | None = None
 | 
				
			||||||
        """The income amount."""
 | 
					        """The income amount."""
 | 
				
			||||||
        self.expense: Decimal | None = None
 | 
					        self.expense: Decimal | None = None
 | 
				
			||||||
@@ -67,24 +69,24 @@ class ReportEntry:
 | 
				
			|||||||
        self.note: str | None = None
 | 
					        self.note: str | None = None
 | 
				
			||||||
        """The note."""
 | 
					        """The note."""
 | 
				
			||||||
        self.url: str | None = None
 | 
					        self.url: str | None = None
 | 
				
			||||||
        """The URL to the journal entry."""
 | 
					        """The URL to the journal entry line item."""
 | 
				
			||||||
        if entry is not None:
 | 
					        if line_item is not None:
 | 
				
			||||||
            self.date = entry.transaction.date
 | 
					            self.date = line_item.journal_entry.date
 | 
				
			||||||
            self.account = entry.account
 | 
					            self.account = line_item.account
 | 
				
			||||||
            self.summary = entry.summary
 | 
					            self.description = line_item.description
 | 
				
			||||||
            self.income = None if entry.is_debit else entry.amount
 | 
					            self.income = None if line_item.is_debit else line_item.amount
 | 
				
			||||||
            self.expense = entry.amount if entry.is_debit else None
 | 
					            self.expense = line_item.amount if line_item.is_debit else None
 | 
				
			||||||
            self.note = entry.transaction.note
 | 
					            self.note = line_item.journal_entry.note
 | 
				
			||||||
            self.url = url_for("accounting.transaction.detail",
 | 
					            self.url = url_for("accounting.journal-entry.detail",
 | 
				
			||||||
                               txn=entry.transaction)
 | 
					                               journal_entry=line_item.journal_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EntryCollector:
 | 
					class LineItemCollector:
 | 
				
			||||||
    """The report entry collector."""
 | 
					    """The line item collector."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, currency: Currency, account: IncomeExpensesAccount,
 | 
					    def __init__(self, currency: Currency, account: IncomeExpensesAccount,
 | 
				
			||||||
                 period: Period):
 | 
					                 period: Period):
 | 
				
			||||||
        """Constructs the report entry collector.
 | 
					        """Constructs the line item collector.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param currency: The currency.
 | 
					        :param currency: The currency.
 | 
				
			||||||
        :param account: The account.
 | 
					        :param account: The account.
 | 
				
			||||||
@@ -96,73 +98,78 @@ class EntryCollector:
 | 
				
			|||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.__period: Period = period
 | 
					        self.__period: Period = period
 | 
				
			||||||
        """The period"""
 | 
					        """The period"""
 | 
				
			||||||
        self.brought_forward: ReportEntry | None
 | 
					        self.brought_forward: ReportLineItem | None
 | 
				
			||||||
        """The brought-forward entry."""
 | 
					        """The brought-forward line item."""
 | 
				
			||||||
        self.entries: list[ReportEntry]
 | 
					        self.line_items: list[ReportLineItem]
 | 
				
			||||||
        """The log entries."""
 | 
					        """The line items."""
 | 
				
			||||||
        self.total: ReportEntry | None
 | 
					        self.total: ReportLineItem | None
 | 
				
			||||||
        """The total entry."""
 | 
					        """The total line item."""
 | 
				
			||||||
        self.brought_forward = self.__get_brought_forward_entry()
 | 
					        self.brought_forward = self.__get_brought_forward()
 | 
				
			||||||
        self.entries = self.__query_entries()
 | 
					        self.line_items = self.__query_line_items()
 | 
				
			||||||
        self.total = self.__get_total_entry()
 | 
					        self.total = self.__get_total()
 | 
				
			||||||
        self.__populate_balance()
 | 
					        self.__populate_balance()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __get_brought_forward_entry(self) -> ReportEntry | None:
 | 
					    def __get_brought_forward(self) -> ReportLineItem | None:
 | 
				
			||||||
        """Queries, composes and returns the brought-forward entry.
 | 
					        """Queries, composes and returns the brought-forward line item.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The brought-forward entry, or None if the period starts from
 | 
					        :return: The brought-forward line item, or None if the period starts
 | 
				
			||||||
            the beginning.
 | 
					            from the beginning.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if self.__period.start is None:
 | 
					        if self.__period.start is None:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
					        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
				
			||||||
            (JournalEntry.is_debit, JournalEntry.amount),
 | 
					            (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
 | 
				
			||||||
            else_=-JournalEntry.amount))
 | 
					            else_=-JournalEntryLineItem.amount))
 | 
				
			||||||
        select: sa.Select = sa.Select(balance_func)\
 | 
					        select: sa.Select = sa.Select(balance_func)\
 | 
				
			||||||
            .join(Transaction).join(Account)\
 | 
					            .join(JournalEntry).join(Account)\
 | 
				
			||||||
            .filter(JournalEntry.currency_code == self.__currency.code,
 | 
					            .filter(be(JournalEntryLineItem.currency_code
 | 
				
			||||||
 | 
					                       == self.__currency.code),
 | 
				
			||||||
                    self.__account_condition,
 | 
					                    self.__account_condition,
 | 
				
			||||||
                    Transaction.date < self.__period.start)
 | 
					                    JournalEntry.date < self.__period.start)
 | 
				
			||||||
        balance: int | None = db.session.scalar(select)
 | 
					        balance: int | None = db.session.scalar(select)
 | 
				
			||||||
        if balance is None:
 | 
					        if balance is None:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        entry: ReportEntry = ReportEntry()
 | 
					        line_item: ReportLineItem = ReportLineItem()
 | 
				
			||||||
        entry.is_brought_forward = True
 | 
					        line_item.is_brought_forward = True
 | 
				
			||||||
        entry.date = self.__period.start
 | 
					        line_item.date = self.__period.start
 | 
				
			||||||
        entry.account = Account.accumulated_change()
 | 
					        line_item.account = Account.accumulated_change()
 | 
				
			||||||
        entry.summary = gettext("Brought forward")
 | 
					        line_item.description = gettext("Brought forward")
 | 
				
			||||||
        if balance > 0:
 | 
					        if balance > 0:
 | 
				
			||||||
            entry.income = balance
 | 
					            line_item.income = balance
 | 
				
			||||||
        elif balance < 0:
 | 
					        elif balance < 0:
 | 
				
			||||||
            entry.expense = -balance
 | 
					            line_item.expense = -balance
 | 
				
			||||||
        entry.balance = balance
 | 
					        line_item.balance = balance
 | 
				
			||||||
        return entry
 | 
					        return line_item
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __query_entries(self) -> list[ReportEntry]:
 | 
					    def __query_line_items(self) -> list[ReportLineItem]:
 | 
				
			||||||
        """Queries and returns the log entries.
 | 
					        """Queries and returns the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The log entries.
 | 
					        :return: The line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
					            = [JournalEntryLineItem.currency_code == self.__currency.code,
 | 
				
			||||||
               self.__account_condition]
 | 
					               self.__account_condition]
 | 
				
			||||||
        if self.__period.start is not None:
 | 
					        if self.__period.start is not None:
 | 
				
			||||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
					            conditions.append(JournalEntry.date >= self.__period.start)
 | 
				
			||||||
        if self.__period.end is not None:
 | 
					        if self.__period.end is not None:
 | 
				
			||||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
					            conditions.append(JournalEntry.date <= self.__period.end)
 | 
				
			||||||
        txn_with_account: sa.Select = sa.Select(Transaction.id).\
 | 
					        journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
 | 
				
			||||||
            join(JournalEntry).join(Account).filter(*conditions)
 | 
					            join(JournalEntryLineItem).join(Account).filter(*conditions)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return [ReportEntry(x)
 | 
					        return [ReportLineItem(x)
 | 
				
			||||||
                for x in JournalEntry.query.join(Transaction).join(Account)
 | 
					                for x in JournalEntryLineItem.query
 | 
				
			||||||
                .filter(JournalEntry.transaction_id.in_(txn_with_account),
 | 
					                .join(JournalEntry).join(Account)
 | 
				
			||||||
                        JournalEntry.currency_code == self.__currency.code,
 | 
					                .filter(JournalEntryLineItem.journal_entry_id
 | 
				
			||||||
 | 
					                        .in_(journal_entry_with_account),
 | 
				
			||||||
 | 
					                        JournalEntryLineItem.currency_code
 | 
				
			||||||
 | 
					                        == self.__currency.code,
 | 
				
			||||||
                        sa.not_(self.__account_condition))
 | 
					                        sa.not_(self.__account_condition))
 | 
				
			||||||
                .order_by(Transaction.date,
 | 
					                .order_by(JournalEntry.date,
 | 
				
			||||||
                          JournalEntry.is_debit,
 | 
					                          JournalEntry.no,
 | 
				
			||||||
                          JournalEntry.no)
 | 
					                          JournalEntryLineItem.is_debit,
 | 
				
			||||||
                .options(selectinload(JournalEntry.account),
 | 
					                          JournalEntryLineItem.no)
 | 
				
			||||||
                         selectinload(JournalEntry.transaction))]
 | 
					                .options(selectinload(JournalEntryLineItem.account),
 | 
				
			||||||
 | 
					                         selectinload(JournalEntryLineItem.journal_entry))]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def __account_condition(self) -> sa.BinaryExpression:
 | 
					    def __account_condition(self) -> sa.BinaryExpression:
 | 
				
			||||||
@@ -173,66 +180,67 @@ class EntryCollector:
 | 
				
			|||||||
                          Account.base_code.startswith("22"))
 | 
					                          Account.base_code.startswith("22"))
 | 
				
			||||||
        return Account.id == self.__account.id
 | 
					        return Account.id == self.__account.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __get_total_entry(self) -> ReportEntry | None:
 | 
					    def __get_total(self) -> ReportLineItem | None:
 | 
				
			||||||
        """Composes the total entry.
 | 
					        """Composes the total line item.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The total entry, or None if there is no data.
 | 
					        :return: The total line item, or None if there is no data.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if self.brought_forward is None and len(self.entries) == 0:
 | 
					        if self.brought_forward is None and len(self.line_items) == 0:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        entry: ReportEntry = ReportEntry()
 | 
					        line_item: ReportLineItem = ReportLineItem()
 | 
				
			||||||
        entry.is_total = True
 | 
					        line_item.is_total = True
 | 
				
			||||||
        entry.summary = gettext("Total")
 | 
					        line_item.description = gettext("Total")
 | 
				
			||||||
        entry.income = sum([x.income for x in self.entries
 | 
					        line_item.income = sum([x.income for x in self.line_items
 | 
				
			||||||
                            if x.income is not None])
 | 
					                                if x.income is not None])
 | 
				
			||||||
        entry.expense = sum([x.expense for x in self.entries
 | 
					        line_item.expense = sum([x.expense for x in self.line_items
 | 
				
			||||||
                             if x.expense is not None])
 | 
					                                 if x.expense is not None])
 | 
				
			||||||
        entry.balance = entry.income - entry.expense
 | 
					        line_item.balance = line_item.income - line_item.expense
 | 
				
			||||||
        if self.brought_forward is not None:
 | 
					        if self.brought_forward is not None:
 | 
				
			||||||
            entry.balance = self.brought_forward.balance + entry.balance
 | 
					            line_item.balance \
 | 
				
			||||||
        return entry
 | 
					                = self.brought_forward.balance + line_item.balance
 | 
				
			||||||
 | 
					        return line_item
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __populate_balance(self) -> None:
 | 
					    def __populate_balance(self) -> None:
 | 
				
			||||||
        """Populates the balance of the entries.
 | 
					        """Populates the balance of the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        balance: Decimal = 0 if self.brought_forward is None \
 | 
					        balance: Decimal = 0 if self.brought_forward is None \
 | 
				
			||||||
            else self.brought_forward.balance
 | 
					            else self.brought_forward.balance
 | 
				
			||||||
        for entry in self.entries:
 | 
					        for line_item in self.line_items:
 | 
				
			||||||
            if entry.income is not None:
 | 
					            if line_item.income is not None:
 | 
				
			||||||
                balance = balance + entry.income
 | 
					                balance = balance + line_item.income
 | 
				
			||||||
            if entry.expense is not None:
 | 
					            if line_item.expense is not None:
 | 
				
			||||||
                balance = balance - entry.expense
 | 
					                balance = balance - line_item.expense
 | 
				
			||||||
            entry.balance = balance
 | 
					            line_item.balance = balance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CSVRow(BaseCSVRow):
 | 
					class CSVRow(BaseCSVRow):
 | 
				
			||||||
    """A row in the CSV."""
 | 
					    """A row in the CSV."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, txn_date: date | str | None,
 | 
					    def __init__(self, journal_entry_date: date | str | None,
 | 
				
			||||||
                 account: str | None,
 | 
					                 account: str | None,
 | 
				
			||||||
                 summary: str | None,
 | 
					                 description: str | None,
 | 
				
			||||||
                 income: str | Decimal | None,
 | 
					                 income: str | Decimal | None,
 | 
				
			||||||
                 expense: str | Decimal | None,
 | 
					                 expense: str | Decimal | None,
 | 
				
			||||||
                 balance: str | Decimal | None,
 | 
					                 balance: str | Decimal | None,
 | 
				
			||||||
                 note: str | None):
 | 
					                 note: str | None):
 | 
				
			||||||
        """Constructs a row in the CSV.
 | 
					        """Constructs a row in the CSV.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param txn_date: The transaction date.
 | 
					        :param journal_entry_date: The journal entry date.
 | 
				
			||||||
        :param account: The account.
 | 
					        :param account: The account.
 | 
				
			||||||
        :param summary: The summary.
 | 
					        :param description: The description.
 | 
				
			||||||
        :param income: The income.
 | 
					        :param income: The income.
 | 
				
			||||||
        :param expense: The expense.
 | 
					        :param expense: The expense.
 | 
				
			||||||
        :param balance: The balance.
 | 
					        :param balance: The balance.
 | 
				
			||||||
        :param note: The note.
 | 
					        :param note: The note.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.date: date | str | None = txn_date
 | 
					        self.date: date | str | None = journal_entry_date
 | 
				
			||||||
        """The date."""
 | 
					        """The date."""
 | 
				
			||||||
        self.account: str | None = account
 | 
					        self.account: str | None = account
 | 
				
			||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.summary: str | None = summary
 | 
					        self.description: str | None = description
 | 
				
			||||||
        """The summary."""
 | 
					        """The description."""
 | 
				
			||||||
        self.income: str | Decimal | None = income
 | 
					        self.income: str | Decimal | None = income
 | 
				
			||||||
        """The income."""
 | 
					        """The income."""
 | 
				
			||||||
        self.expense: str | Decimal | None = expense
 | 
					        self.expense: str | Decimal | None = expense
 | 
				
			||||||
@@ -248,7 +256,7 @@ class CSVRow(BaseCSVRow):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The values of the row.
 | 
					        :return: The values of the row.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return [self.date, self.account, self.summary,
 | 
					        return [self.date, self.account, self.description,
 | 
				
			||||||
                self.income, self.expense, self.balance, self.note]
 | 
					                self.income, self.expense, self.balance, self.note]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -259,19 +267,19 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
                 account: IncomeExpensesAccount,
 | 
					                 account: IncomeExpensesAccount,
 | 
				
			||||||
                 period: Period,
 | 
					                 period: Period,
 | 
				
			||||||
                 has_data: bool,
 | 
					                 has_data: bool,
 | 
				
			||||||
                 pagination: Pagination[ReportEntry],
 | 
					                 pagination: Pagination[ReportLineItem],
 | 
				
			||||||
                 brought_forward: ReportEntry | None,
 | 
					                 brought_forward: ReportLineItem | None,
 | 
				
			||||||
                 entries: list[ReportEntry],
 | 
					                 line_items: list[ReportLineItem],
 | 
				
			||||||
                 total: ReportEntry | None):
 | 
					                 total: ReportLineItem | None):
 | 
				
			||||||
        """Constructs the HTML page parameters.
 | 
					        """Constructs the HTML page parameters.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param currency: The currency.
 | 
					        :param currency: The currency.
 | 
				
			||||||
        :param account: The account.
 | 
					        :param account: The account.
 | 
				
			||||||
        :param period: The period.
 | 
					        :param period: The period.
 | 
				
			||||||
        :param has_data: True if there is any data, or False otherwise.
 | 
					        :param has_data: True if there is any data, or False otherwise.
 | 
				
			||||||
        :param brought_forward: The brought-forward entry.
 | 
					        :param brought_forward: The brought-forward line item.
 | 
				
			||||||
        :param entries: The log entries.
 | 
					        :param line_items: The line items.
 | 
				
			||||||
        :param total: The total entry.
 | 
					        :param total: The total line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.currency: Currency = currency
 | 
					        self.currency: Currency = currency
 | 
				
			||||||
        """The currency."""
 | 
					        """The currency."""
 | 
				
			||||||
@@ -281,14 +289,14 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
        """The period."""
 | 
					        """The period."""
 | 
				
			||||||
        self.__has_data: bool = has_data
 | 
					        self.__has_data: bool = has_data
 | 
				
			||||||
        """True if there is any data, or False otherwise."""
 | 
					        """True if there is any data, or False otherwise."""
 | 
				
			||||||
        self.pagination: Pagination[ReportEntry] = pagination
 | 
					        self.pagination: Pagination[ReportLineItem] = pagination
 | 
				
			||||||
        """The pagination."""
 | 
					        """The pagination."""
 | 
				
			||||||
        self.brought_forward: ReportEntry | None = brought_forward
 | 
					        self.brought_forward: ReportLineItem | None = brought_forward
 | 
				
			||||||
        """The brought-forward entry."""
 | 
					        """The brought-forward line item."""
 | 
				
			||||||
        self.entries: list[ReportEntry] = entries
 | 
					        self.line_items: list[ReportLineItem] = line_items
 | 
				
			||||||
        """The report entries."""
 | 
					        """The line items."""
 | 
				
			||||||
        self.total: ReportEntry | None = total
 | 
					        self.total: ReportLineItem | None = total
 | 
				
			||||||
        """The total entry."""
 | 
					        """The total line item."""
 | 
				
			||||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
					        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
				
			||||||
            lambda x: income_expenses_url(currency, account, x))
 | 
					            lambda x: income_expenses_url(currency, account, x))
 | 
				
			||||||
        """The period chooser."""
 | 
					        """The period chooser."""
 | 
				
			||||||
@@ -340,14 +348,15 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
                          income_expenses_url(self.currency, current_al,
 | 
					                          income_expenses_url(self.currency, current_al,
 | 
				
			||||||
                                              self.period),
 | 
					                                              self.period),
 | 
				
			||||||
                          self.account.id == 0)]
 | 
					                          self.account.id == 0)]
 | 
				
			||||||
        in_use: sa.Select = sa.Select(JournalEntry.account_id)\
 | 
					        in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
 | 
				
			||||||
            .join(Account)\
 | 
					            .join(Account)\
 | 
				
			||||||
            .filter(JournalEntry.currency_code == self.currency.code,
 | 
					            .filter(be(JournalEntryLineItem.currency_code
 | 
				
			||||||
 | 
					                       == self.currency.code),
 | 
				
			||||||
                    sa.or_(Account.base_code.startswith("11"),
 | 
					                    sa.or_(Account.base_code.startswith("11"),
 | 
				
			||||||
                           Account.base_code.startswith("12"),
 | 
					                           Account.base_code.startswith("12"),
 | 
				
			||||||
                           Account.base_code.startswith("21"),
 | 
					                           Account.base_code.startswith("21"),
 | 
				
			||||||
                           Account.base_code.startswith("22")))\
 | 
					                           Account.base_code.startswith("22")))\
 | 
				
			||||||
            .group_by(JournalEntry.account_id)
 | 
					            .group_by(JournalEntryLineItem.account_id)
 | 
				
			||||||
        options.extend([OptionLink(str(x),
 | 
					        options.extend([OptionLink(str(x),
 | 
				
			||||||
                                   income_expenses_url(
 | 
					                                   income_expenses_url(
 | 
				
			||||||
                                       self.currency,
 | 
					                                       self.currency,
 | 
				
			||||||
@@ -376,14 +385,15 @@ class IncomeExpenses(BaseReport):
 | 
				
			|||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.__period: Period = period
 | 
					        self.__period: Period = period
 | 
				
			||||||
        """The period."""
 | 
					        """The period."""
 | 
				
			||||||
        collector: EntryCollector = EntryCollector(
 | 
					        collector: LineItemCollector = LineItemCollector(
 | 
				
			||||||
            self.__currency, self.__account, self.__period)
 | 
					            self.__currency, self.__account, self.__period)
 | 
				
			||||||
        self.__brought_forward: ReportEntry | None = collector.brought_forward
 | 
					        self.__brought_forward: ReportLineItem | None \
 | 
				
			||||||
        """The brought-forward entry."""
 | 
					            = collector.brought_forward
 | 
				
			||||||
        self.__entries: list[ReportEntry] = collector.entries
 | 
					        """The brought-forward line item."""
 | 
				
			||||||
        """The report entries."""
 | 
					        self.__line_items: list[ReportLineItem] = collector.line_items
 | 
				
			||||||
        self.__total: ReportEntry | None = collector.total
 | 
					        """The line items."""
 | 
				
			||||||
        """The total entry."""
 | 
					        self.__total: ReportLineItem | None = collector.total
 | 
				
			||||||
 | 
					        """The total line item."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def csv(self) -> Response:
 | 
					    def csv(self) -> Response:
 | 
				
			||||||
        """Returns the report as CSV for download.
 | 
					        """Returns the report as CSV for download.
 | 
				
			||||||
@@ -401,20 +411,20 @@ class IncomeExpenses(BaseReport):
 | 
				
			|||||||
        :return: The CSV rows.
 | 
					        :return: The CSV rows.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
 | 
					        rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
 | 
				
			||||||
                                     gettext("Summary"), gettext("Income"),
 | 
					                                     gettext("Description"), gettext("Income"),
 | 
				
			||||||
                                     gettext("Expense"), gettext("Balance"),
 | 
					                                     gettext("Expense"), gettext("Balance"),
 | 
				
			||||||
                                     gettext("Note"))]
 | 
					                                     gettext("Note"))]
 | 
				
			||||||
        if self.__brought_forward is not None:
 | 
					        if self.__brought_forward is not None:
 | 
				
			||||||
            rows.append(CSVRow(self.__brought_forward.date,
 | 
					            rows.append(CSVRow(self.__brought_forward.date,
 | 
				
			||||||
                               str(self.__brought_forward.account).title(),
 | 
					                               str(self.__brought_forward.account).title(),
 | 
				
			||||||
                               self.__brought_forward.summary,
 | 
					                               self.__brought_forward.description,
 | 
				
			||||||
                               self.__brought_forward.income,
 | 
					                               self.__brought_forward.income,
 | 
				
			||||||
                               self.__brought_forward.expense,
 | 
					                               self.__brought_forward.expense,
 | 
				
			||||||
                               self.__brought_forward.balance,
 | 
					                               self.__brought_forward.balance,
 | 
				
			||||||
                               None))
 | 
					                               None))
 | 
				
			||||||
        rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
 | 
					        rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
 | 
				
			||||||
                            x.income, x.expense, x.balance, x.note)
 | 
					                            x.income, x.expense, x.balance, x.note)
 | 
				
			||||||
                     for x in self.__entries])
 | 
					                     for x in self.__line_items])
 | 
				
			||||||
        if self.__total is not None:
 | 
					        if self.__total is not None:
 | 
				
			||||||
            rows.append(CSVRow(gettext("Total"), None, None,
 | 
					            rows.append(CSVRow(gettext("Total"), None, None,
 | 
				
			||||||
                               self.__total.income, self.__total.expense,
 | 
					                               self.__total.income, self.__total.expense,
 | 
				
			||||||
@@ -426,31 +436,31 @@ class IncomeExpenses(BaseReport):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The report as HTML.
 | 
					        :return: The report as HTML.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        all_entries: list[ReportEntry] = []
 | 
					        all_line_items: list[ReportLineItem] = []
 | 
				
			||||||
        if self.__brought_forward is not None:
 | 
					        if self.__brought_forward is not None:
 | 
				
			||||||
            all_entries.append(self.__brought_forward)
 | 
					            all_line_items.append(self.__brought_forward)
 | 
				
			||||||
        all_entries.extend(self.__entries)
 | 
					        all_line_items.extend(self.__line_items)
 | 
				
			||||||
        if self.__total is not None:
 | 
					        if self.__total is not None:
 | 
				
			||||||
            all_entries.append(self.__total)
 | 
					            all_line_items.append(self.__total)
 | 
				
			||||||
        pagination: Pagination[ReportEntry] \
 | 
					        pagination: Pagination[ReportLineItem] \
 | 
				
			||||||
            = Pagination[ReportEntry](all_entries)
 | 
					            = Pagination[ReportLineItem](all_line_items, is_reversed=True)
 | 
				
			||||||
        page_entries: list[ReportEntry] = pagination.list
 | 
					        page_line_items: list[ReportLineItem] = pagination.list
 | 
				
			||||||
        has_data: bool = len(page_entries) > 0
 | 
					        has_data: bool = len(page_line_items) > 0
 | 
				
			||||||
        brought_forward: ReportEntry | None = None
 | 
					        brought_forward: ReportLineItem | None = None
 | 
				
			||||||
        if len(page_entries) > 0 and page_entries[0].is_brought_forward:
 | 
					        if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
 | 
				
			||||||
            brought_forward = page_entries[0]
 | 
					            brought_forward = page_line_items[0]
 | 
				
			||||||
            page_entries = page_entries[1:]
 | 
					            page_line_items = page_line_items[1:]
 | 
				
			||||||
        total: ReportEntry | None = None
 | 
					        total: ReportLineItem | None = None
 | 
				
			||||||
        if len(page_entries) > 0 and page_entries[-1].is_total:
 | 
					        if len(page_line_items) > 0 and page_line_items[-1].is_total:
 | 
				
			||||||
            total = page_entries[-1]
 | 
					            total = page_line_items[-1]
 | 
				
			||||||
            page_entries = page_entries[:-1]
 | 
					            page_line_items = page_line_items[:-1]
 | 
				
			||||||
        params: PageParams = PageParams(currency=self.__currency,
 | 
					        params: PageParams = PageParams(currency=self.__currency,
 | 
				
			||||||
                                        account=self.__account,
 | 
					                                        account=self.__account,
 | 
				
			||||||
                                        period=self.__period,
 | 
					                                        period=self.__period,
 | 
				
			||||||
                                        has_data=has_data,
 | 
					                                        has_data=has_data,
 | 
				
			||||||
                                        pagination=pagination,
 | 
					                                        pagination=pagination,
 | 
				
			||||||
                                        brought_forward=brought_forward,
 | 
					                                        brought_forward=brought_forward,
 | 
				
			||||||
                                        entries=page_entries,
 | 
					                                        line_items=page_line_items,
 | 
				
			||||||
                                        total=total)
 | 
					                                        total=total)
 | 
				
			||||||
        return render_template("accounting/report/income-expenses.html",
 | 
					        return render_template("accounting/report/income-expenses.html",
 | 
				
			||||||
                               report=params)
 | 
					                               report=params)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,8 +24,8 @@ from flask import render_template, Response
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext
 | 
				
			||||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
 | 
					from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
 | 
				
			||||||
    JournalEntry
 | 
					    JournalEntryLineItem
 | 
				
			||||||
from accounting.report.period import Period, PeriodChooser
 | 
					from accounting.report.period import Period, PeriodChooser
 | 
				
			||||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
					from accounting.report.utils.base_page_params import BasePageParams
 | 
				
			||||||
from accounting.report.utils.base_report import BaseReport
 | 
					from accounting.report.utils.base_report import BaseReport
 | 
				
			||||||
@@ -256,17 +256,17 @@ class IncomeStatement(BaseReport):
 | 
				
			|||||||
        sub_conditions: list[sa.BinaryExpression] \
 | 
					        sub_conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
 | 
					            = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
					            = [JournalEntryLineItem.currency_code == self.__currency.code,
 | 
				
			||||||
               sa.or_(*sub_conditions)]
 | 
					               sa.or_(*sub_conditions)]
 | 
				
			||||||
        if self.__period.start is not None:
 | 
					        if self.__period.start is not None:
 | 
				
			||||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
					            conditions.append(JournalEntry.date >= self.__period.start)
 | 
				
			||||||
        if self.__period.end is not None:
 | 
					        if self.__period.end is not None:
 | 
				
			||||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
					            conditions.append(JournalEntry.date <= self.__period.end)
 | 
				
			||||||
        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
					        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
				
			||||||
            (JournalEntry.is_debit, -JournalEntry.amount),
 | 
					            (JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
 | 
				
			||||||
            else_=JournalEntry.amount)).label("balance")
 | 
					            else_=JournalEntryLineItem.amount)).label("balance")
 | 
				
			||||||
        select_balances: sa.Select = sa.select(Account.id, balance_func)\
 | 
					        select_balances: sa.Select = sa.select(Account.id, balance_func)\
 | 
				
			||||||
            .join(Transaction).join(Account)\
 | 
					            .join(JournalEntry).join(Account)\
 | 
				
			||||||
            .filter(*conditions)\
 | 
					            .filter(*conditions)\
 | 
				
			||||||
            .group_by(Account.id)\
 | 
					            .group_by(Account.id)\
 | 
				
			||||||
            .order_by(Account.base_code, Account.no)
 | 
					            .order_by(Account.base_code, Account.no)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,7 +25,8 @@ from flask import render_template, Response
 | 
				
			|||||||
from sqlalchemy.orm import selectinload
 | 
					from sqlalchemy.orm import selectinload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext
 | 
				
			||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
 | 
					from accounting.models import Currency, Account, JournalEntry, \
 | 
				
			||||||
 | 
					    JournalEntryLineItem
 | 
				
			||||||
from accounting.report.period import Period, PeriodChooser
 | 
					from accounting.report.period import Period, PeriodChooser
 | 
				
			||||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
					from accounting.report.utils.base_page_params import BasePageParams
 | 
				
			||||||
from accounting.report.utils.base_report import BaseReport
 | 
					from accounting.report.utils.base_report import BaseReport
 | 
				
			||||||
@@ -37,58 +38,58 @@ from accounting.report.utils.urls import journal_url
 | 
				
			|||||||
from accounting.utils.pagination import Pagination
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ReportEntry:
 | 
					class ReportLineItem:
 | 
				
			||||||
    """An entry in the report."""
 | 
					    """A line item in the report."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, entry: JournalEntry):
 | 
					    def __init__(self, line_item: JournalEntryLineItem):
 | 
				
			||||||
        """Constructs the entry in the report.
 | 
					        """Constructs the line item in the report.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param entry: The journal entry.
 | 
					        :param line_item: The journal entry line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.entry: JournalEntry = entry
 | 
					        self.line_item: JournalEntryLineItem = line_item
 | 
				
			||||||
 | 
					        """The journal entry line item."""
 | 
				
			||||||
 | 
					        self.journal_entry: JournalEntry = line_item.journal_entry
 | 
				
			||||||
        """The journal entry."""
 | 
					        """The journal entry."""
 | 
				
			||||||
        self.transaction: Transaction = entry.transaction
 | 
					        self.currency: Currency = line_item.currency
 | 
				
			||||||
        """The transaction."""
 | 
					 | 
				
			||||||
        self.currency: Currency = entry.currency
 | 
					 | 
				
			||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.account: Account = entry.account
 | 
					        self.account: Account = line_item.account
 | 
				
			||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.summary: str | None = entry.summary
 | 
					        self.description: str | None = line_item.description
 | 
				
			||||||
        """The summary."""
 | 
					        """The description."""
 | 
				
			||||||
        self.debit: Decimal | None = entry.debit
 | 
					        self.debit: Decimal | None = line_item.debit
 | 
				
			||||||
        """The debit amount."""
 | 
					        """The debit amount."""
 | 
				
			||||||
        self.credit: Decimal | None = entry.credit
 | 
					        self.credit: Decimal | None = line_item.credit
 | 
				
			||||||
        """The credit amount."""
 | 
					        """The credit amount."""
 | 
				
			||||||
        self.amount: Decimal = entry.amount
 | 
					        self.amount: Decimal = line_item.amount
 | 
				
			||||||
        """The amount."""
 | 
					        """The amount."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CSVRow(BaseCSVRow):
 | 
					class CSVRow(BaseCSVRow):
 | 
				
			||||||
    """A row in the CSV."""
 | 
					    """A row in the CSV."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, txn_date: str | date,
 | 
					    def __init__(self, journal_entry_date: str | date,
 | 
				
			||||||
                 currency: str,
 | 
					                 currency: str,
 | 
				
			||||||
                 account: str,
 | 
					                 account: str,
 | 
				
			||||||
                 summary: str | None,
 | 
					                 description: str | None,
 | 
				
			||||||
                 debit: str | Decimal | None,
 | 
					                 debit: str | Decimal | None,
 | 
				
			||||||
                 credit: str | Decimal | None,
 | 
					                 credit: str | Decimal | None,
 | 
				
			||||||
                 note: str | None):
 | 
					                 note: str | None):
 | 
				
			||||||
        """Constructs a row in the CSV.
 | 
					        """Constructs a row in the CSV.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param txn_date: The transaction date.
 | 
					        :param journal_entry_date: The journal entry date.
 | 
				
			||||||
        :param summary: The summary.
 | 
					        :param description: The description.
 | 
				
			||||||
        :param debit: The debit amount.
 | 
					        :param debit: The debit amount.
 | 
				
			||||||
        :param credit: The credit amount.
 | 
					        :param credit: The credit amount.
 | 
				
			||||||
        :param note: The note.
 | 
					        :param note: The note.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.date: str | date = txn_date
 | 
					        self.date: str | date = journal_entry_date
 | 
				
			||||||
        """The date."""
 | 
					        """The date."""
 | 
				
			||||||
        self.currency: str = currency
 | 
					        self.currency: str = currency
 | 
				
			||||||
        """The currency."""
 | 
					        """The currency."""
 | 
				
			||||||
        self.account: str = account
 | 
					        self.account: str = account
 | 
				
			||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.summary: str | None = summary
 | 
					        self.description: str | None = description
 | 
				
			||||||
        """The summary."""
 | 
					        """The description."""
 | 
				
			||||||
        self.debit: str | Decimal | None = debit
 | 
					        self.debit: str | Decimal | None = debit
 | 
				
			||||||
        """The debit amount."""
 | 
					        """The debit amount."""
 | 
				
			||||||
        self.credit: str | Decimal | None = credit
 | 
					        self.credit: str | Decimal | None = credit
 | 
				
			||||||
@@ -102,7 +103,7 @@ class CSVRow(BaseCSVRow):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The values of the row.
 | 
					        :return: The values of the row.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return [self.date, self.currency, self.account, self.summary,
 | 
					        return [self.date, self.currency, self.account, self.description,
 | 
				
			||||||
                self.debit, self.credit, self.note]
 | 
					                self.debit, self.credit, self.note]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -110,19 +111,19 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
    """The HTML page parameters."""
 | 
					    """The HTML page parameters."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, period: Period,
 | 
					    def __init__(self, period: Period,
 | 
				
			||||||
                 pagination: Pagination[JournalEntry],
 | 
					                 pagination: Pagination[JournalEntryLineItem],
 | 
				
			||||||
                 entries: list[JournalEntry]):
 | 
					                 line_items: list[JournalEntryLineItem]):
 | 
				
			||||||
        """Constructs the HTML page parameters.
 | 
					        """Constructs the HTML page parameters.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param period: The period.
 | 
					        :param period: The period.
 | 
				
			||||||
        :param entries: The journal entries.
 | 
					        :param line_items: The line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.period: Period = period
 | 
					        self.period: Period = period
 | 
				
			||||||
        """The period."""
 | 
					        """The period."""
 | 
				
			||||||
        self.pagination: Pagination[JournalEntry] = pagination
 | 
					        self.pagination: Pagination[JournalEntryLineItem] = pagination
 | 
				
			||||||
        """The pagination."""
 | 
					        """The pagination."""
 | 
				
			||||||
        self.entries: list[JournalEntry] = entries
 | 
					        self.line_items: list[JournalEntryLineItem] = line_items
 | 
				
			||||||
        """The entries."""
 | 
					        """The line items."""
 | 
				
			||||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
					        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
				
			||||||
            lambda x: journal_url(x))
 | 
					            lambda x: journal_url(x))
 | 
				
			||||||
        """The period chooser."""
 | 
					        """The period chooser."""
 | 
				
			||||||
@@ -133,7 +134,7 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: True if there is any data, or False otherwise.
 | 
					        :return: True if there is any data, or False otherwise.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return len(self.entries) > 0
 | 
					        return len(self.line_items) > 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def report_chooser(self) -> ReportChooser:
 | 
					    def report_chooser(self) -> ReportChooser:
 | 
				
			||||||
@@ -145,20 +146,20 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
                             period=self.period)
 | 
					                             period=self.period)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
 | 
					def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
 | 
				
			||||||
    """Composes and returns the CSV rows from the report entries.
 | 
					    """Composes and returns the CSV rows from the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param entries: The report entries.
 | 
					    :param line_items: The line items.
 | 
				
			||||||
    :return: The CSV rows.
 | 
					    :return: The CSV rows.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
 | 
					    rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
 | 
				
			||||||
                                 gettext("Account"), gettext("Summary"),
 | 
					                                 gettext("Account"), gettext("Description"),
 | 
				
			||||||
                                 gettext("Debit"), gettext("Credit"),
 | 
					                                 gettext("Debit"), gettext("Credit"),
 | 
				
			||||||
                                 gettext("Note"))]
 | 
					                                 gettext("Note"))]
 | 
				
			||||||
    rows.extend([CSVRow(x.transaction.date, x.currency.code,
 | 
					    rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
 | 
				
			||||||
                        str(x.account).title(), x.summary,
 | 
					                        str(x.account).title(), x.description,
 | 
				
			||||||
                        x.debit, x.credit, x.transaction.note)
 | 
					                        x.debit, x.credit, x.journal_entry.note)
 | 
				
			||||||
                 for x in entries])
 | 
					                 for x in line_items])
 | 
				
			||||||
    return rows
 | 
					    return rows
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -172,27 +173,29 @@ class Journal(BaseReport):
 | 
				
			|||||||
        """
 | 
					        """
 | 
				
			||||||
        self.__period: Period = period
 | 
					        self.__period: Period = period
 | 
				
			||||||
        """The period."""
 | 
					        """The period."""
 | 
				
			||||||
        self.__entries: list[JournalEntry] = self.__query_entries()
 | 
					        self.__line_items: list[JournalEntryLineItem] \
 | 
				
			||||||
        """The journal entries."""
 | 
					            = self.__query_line_items()
 | 
				
			||||||
 | 
					        """The line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __query_entries(self) -> list[JournalEntry]:
 | 
					    def __query_line_items(self) -> list[JournalEntryLineItem]:
 | 
				
			||||||
        """Queries and returns the journal entries.
 | 
					        """Queries and returns the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The journal entries.
 | 
					        :return: The line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] = []
 | 
					        conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
        if self.__period.start is not None:
 | 
					        if self.__period.start is not None:
 | 
				
			||||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
					            conditions.append(JournalEntry.date >= self.__period.start)
 | 
				
			||||||
        if self.__period.end is not None:
 | 
					        if self.__period.end is not None:
 | 
				
			||||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
					            conditions.append(JournalEntry.date <= self.__period.end)
 | 
				
			||||||
        return JournalEntry.query.join(Transaction)\
 | 
					        return JournalEntryLineItem.query.join(JournalEntry)\
 | 
				
			||||||
            .filter(*conditions)\
 | 
					            .filter(*conditions)\
 | 
				
			||||||
            .order_by(Transaction.date,
 | 
					            .order_by(JournalEntry.date,
 | 
				
			||||||
                      JournalEntry.is_debit.desc(),
 | 
					                      JournalEntry.no,
 | 
				
			||||||
                      JournalEntry.no)\
 | 
					                      JournalEntryLineItem.is_debit.desc(),
 | 
				
			||||||
            .options(selectinload(JournalEntry.account),
 | 
					                      JournalEntryLineItem.no)\
 | 
				
			||||||
                     selectinload(JournalEntry.currency),
 | 
					            .options(selectinload(JournalEntryLineItem.account),
 | 
				
			||||||
                     selectinload(JournalEntry.transaction)).all()
 | 
					                     selectinload(JournalEntryLineItem.currency),
 | 
				
			||||||
 | 
					                     selectinload(JournalEntryLineItem.journal_entry)).all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def csv(self) -> Response:
 | 
					    def csv(self) -> Response:
 | 
				
			||||||
        """Returns the report as CSV for download.
 | 
					        """Returns the report as CSV for download.
 | 
				
			||||||
@@ -200,17 +203,18 @@ class Journal(BaseReport):
 | 
				
			|||||||
        :return: The response of the report for download.
 | 
					        :return: The response of the report for download.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        filename: str = f"journal-{period_spec(self.__period)}.csv"
 | 
					        filename: str = f"journal-{period_spec(self.__period)}.csv"
 | 
				
			||||||
        return csv_download(filename, get_csv_rows(self.__entries))
 | 
					        return csv_download(filename, get_csv_rows(self.__line_items))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def html(self) -> str:
 | 
					    def html(self) -> str:
 | 
				
			||||||
        """Composes and returns the report as HTML.
 | 
					        """Composes and returns the report as HTML.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The report as HTML.
 | 
					        :return: The report as HTML.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        pagination: Pagination[JournalEntry] \
 | 
					        pagination: Pagination[JournalEntryLineItem] \
 | 
				
			||||||
            = Pagination[JournalEntry](self.__entries)
 | 
					            = Pagination[JournalEntryLineItem](self.__line_items,
 | 
				
			||||||
 | 
					                                               is_reversed=True)
 | 
				
			||||||
        params: PageParams = PageParams(period=self.__period,
 | 
					        params: PageParams = PageParams(period=self.__period,
 | 
				
			||||||
                                        pagination=pagination,
 | 
					                                        pagination=pagination,
 | 
				
			||||||
                                        entries=pagination.list)
 | 
					                                        line_items=pagination.list)
 | 
				
			||||||
        return render_template("accounting/report/journal.html",
 | 
					        return render_template("accounting/report/journal.html",
 | 
				
			||||||
                               report=params)
 | 
					                               report=params)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext
 | 
				
			||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
 | 
					from accounting.models import Currency, Account, JournalEntry, \
 | 
				
			||||||
 | 
					    JournalEntryLineItem
 | 
				
			||||||
from accounting.report.period import Period, PeriodChooser
 | 
					from accounting.report.period import Period, PeriodChooser
 | 
				
			||||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
					from accounting.report.utils.base_page_params import BasePageParams
 | 
				
			||||||
from accounting.report.utils.base_report import BaseReport
 | 
					from accounting.report.utils.base_report import BaseReport
 | 
				
			||||||
@@ -36,25 +37,26 @@ from accounting.report.utils.option_link import OptionLink
 | 
				
			|||||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
					from accounting.report.utils.report_chooser import ReportChooser
 | 
				
			||||||
from accounting.report.utils.report_type import ReportType
 | 
					from accounting.report.utils.report_type import ReportType
 | 
				
			||||||
from accounting.report.utils.urls import ledger_url
 | 
					from accounting.report.utils.urls import ledger_url
 | 
				
			||||||
 | 
					from accounting.utils.cast import be
 | 
				
			||||||
from accounting.utils.pagination import Pagination
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ReportEntry:
 | 
					class ReportLineItem:
 | 
				
			||||||
    """An entry in the report."""
 | 
					    """A line item in the report."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, entry: JournalEntry | None = None):
 | 
					    def __init__(self, line_item: JournalEntryLineItem | None = None):
 | 
				
			||||||
        """Constructs the entry in the report.
 | 
					        """Constructs the line item in the report.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param entry: The journal entry.
 | 
					        :param line_item: The journal entry line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.is_brought_forward: bool = False
 | 
					        self.is_brought_forward: bool = False
 | 
				
			||||||
        """Whether this is the brought-forward entry."""
 | 
					        """Whether this is the brought-forward line item."""
 | 
				
			||||||
        self.is_total: bool = False
 | 
					        self.is_total: bool = False
 | 
				
			||||||
        """Whether this is the total entry."""
 | 
					        """Whether this is the total line item."""
 | 
				
			||||||
        self.date: date | None = None
 | 
					        self.date: date | None = None
 | 
				
			||||||
        """The date."""
 | 
					        """The date."""
 | 
				
			||||||
        self.summary: str | None = None
 | 
					        self.description: str | None = None
 | 
				
			||||||
        """The summary."""
 | 
					        """The description."""
 | 
				
			||||||
        self.debit: Decimal | None = None
 | 
					        self.debit: Decimal | None = None
 | 
				
			||||||
        """The debit amount."""
 | 
					        """The debit amount."""
 | 
				
			||||||
        self.credit: Decimal | None = None
 | 
					        self.credit: Decimal | None = None
 | 
				
			||||||
@@ -64,22 +66,22 @@ class ReportEntry:
 | 
				
			|||||||
        self.note: str | None = None
 | 
					        self.note: str | None = None
 | 
				
			||||||
        """The note."""
 | 
					        """The note."""
 | 
				
			||||||
        self.url: str | None = None
 | 
					        self.url: str | None = None
 | 
				
			||||||
        """The URL to the journal entry."""
 | 
					        """The URL to the journal entry line item."""
 | 
				
			||||||
        if entry is not None:
 | 
					        if line_item is not None:
 | 
				
			||||||
            self.date = entry.transaction.date
 | 
					            self.date = line_item.journal_entry.date
 | 
				
			||||||
            self.summary = entry.summary
 | 
					            self.description = line_item.description
 | 
				
			||||||
            self.debit = entry.amount if entry.is_debit else None
 | 
					            self.debit = line_item.amount if line_item.is_debit else None
 | 
				
			||||||
            self.credit = None if entry.is_debit else entry.amount
 | 
					            self.credit = None if line_item.is_debit else line_item.amount
 | 
				
			||||||
            self.note = entry.transaction.note
 | 
					            self.note = line_item.journal_entry.note
 | 
				
			||||||
            self.url = url_for("accounting.transaction.detail",
 | 
					            self.url = url_for("accounting.journal-entry.detail",
 | 
				
			||||||
                               txn=entry.transaction)
 | 
					                               journal_entry=line_item.journal_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EntryCollector:
 | 
					class LineItemCollector:
 | 
				
			||||||
    """The report entry collector."""
 | 
					    """The line item collector."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, currency: Currency, account: Account, period: Period):
 | 
					    def __init__(self, currency: Currency, account: Account, period: Period):
 | 
				
			||||||
        """Constructs the report entry collector.
 | 
					        """Constructs the line item collector.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param currency: The currency.
 | 
					        :param currency: The currency.
 | 
				
			||||||
        :param account: The account.
 | 
					        :param account: The account.
 | 
				
			||||||
@@ -91,123 +93,131 @@ class EntryCollector:
 | 
				
			|||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.__period: Period = period
 | 
					        self.__period: Period = period
 | 
				
			||||||
        """The period"""
 | 
					        """The period"""
 | 
				
			||||||
        self.brought_forward: ReportEntry | None
 | 
					        self.brought_forward: ReportLineItem | None
 | 
				
			||||||
        """The brought-forward entry."""
 | 
					        """The brought-forward line item."""
 | 
				
			||||||
        self.entries: list[ReportEntry]
 | 
					        self.line_items: list[ReportLineItem]
 | 
				
			||||||
        """The report entries."""
 | 
					        """The line items."""
 | 
				
			||||||
        self.total: ReportEntry | None
 | 
					        self.total: ReportLineItem | None
 | 
				
			||||||
        """The total entry."""
 | 
					        """The total line item."""
 | 
				
			||||||
        self.brought_forward = self.__get_brought_forward_entry()
 | 
					        self.brought_forward = self.__get_brought_forward()
 | 
				
			||||||
        self.entries = self.__query_entries()
 | 
					        self.line_items = self.__query_line_items()
 | 
				
			||||||
        self.total = self.__get_total_entry()
 | 
					        self.total = self.__get_total()
 | 
				
			||||||
        self.__populate_balance()
 | 
					        self.__populate_balance()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __get_brought_forward_entry(self) -> ReportEntry | None:
 | 
					    def __get_brought_forward(self) -> ReportLineItem | None:
 | 
				
			||||||
        """Queries, composes and returns the brought-forward entry.
 | 
					        """Queries, composes and returns the brought-forward line item.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The brought-forward entry, or None if the report starts from
 | 
					        :return: The brought-forward line item, or None if the report starts
 | 
				
			||||||
            the beginning.
 | 
					            from the beginning.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if self.__period.start is None:
 | 
					        if self.__period.start is None:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        if self.__account.base_code[0] not in {"1", "2", "3"}:
 | 
					        if self.__account.is_nominal:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
					        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
				
			||||||
            (JournalEntry.is_debit, JournalEntry.amount),
 | 
					            (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
 | 
				
			||||||
            else_=-JournalEntry.amount))
 | 
					            else_=-JournalEntryLineItem.amount))
 | 
				
			||||||
        select: sa.Select = sa.Select(balance_func).join(Transaction)\
 | 
					        select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
 | 
				
			||||||
            .filter(JournalEntry.currency_code == self.__currency.code,
 | 
					            .filter(be(JournalEntryLineItem.currency_code
 | 
				
			||||||
                    JournalEntry.account_id == self.__account.id,
 | 
					                       == self.__currency.code),
 | 
				
			||||||
                    Transaction.date < self.__period.start)
 | 
					                    be(JournalEntryLineItem.account_id
 | 
				
			||||||
 | 
					                       == self.__account.id),
 | 
				
			||||||
 | 
					                    JournalEntry.date < self.__period.start)
 | 
				
			||||||
        balance: int | None = db.session.scalar(select)
 | 
					        balance: int | None = db.session.scalar(select)
 | 
				
			||||||
        if balance is None:
 | 
					        if balance is None:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        entry: ReportEntry = ReportEntry()
 | 
					        line_item: ReportLineItem = ReportLineItem()
 | 
				
			||||||
        entry.is_brought_forward = True
 | 
					        line_item.is_brought_forward = True
 | 
				
			||||||
        entry.date = self.__period.start
 | 
					        line_item.date = self.__period.start
 | 
				
			||||||
        entry.summary = gettext("Brought forward")
 | 
					        line_item.description = gettext("Brought forward")
 | 
				
			||||||
        if balance > 0:
 | 
					        if balance > 0:
 | 
				
			||||||
            entry.debit = balance
 | 
					            line_item.debit = balance
 | 
				
			||||||
        elif balance < 0:
 | 
					        elif balance < 0:
 | 
				
			||||||
            entry.credit = -balance
 | 
					            line_item.credit = -balance
 | 
				
			||||||
        entry.balance = balance
 | 
					        line_item.balance = balance
 | 
				
			||||||
        return entry
 | 
					        return line_item
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __query_entries(self) -> list[ReportEntry]:
 | 
					    def __query_line_items(self) -> list[ReportLineItem]:
 | 
				
			||||||
        """Queries and returns the report entries.
 | 
					        """Queries and returns the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The report entries.
 | 
					        :return: The line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
					            = [JournalEntryLineItem.currency_code == self.__currency.code,
 | 
				
			||||||
               JournalEntry.account_id == self.__account.id]
 | 
					               JournalEntryLineItem.account_id == self.__account.id]
 | 
				
			||||||
        if self.__period.start is not None:
 | 
					        if self.__period.start is not None:
 | 
				
			||||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
					            conditions.append(JournalEntry.date >= self.__period.start)
 | 
				
			||||||
        if self.__period.end is not None:
 | 
					        if self.__period.end is not None:
 | 
				
			||||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
					            conditions.append(JournalEntry.date <= self.__period.end)
 | 
				
			||||||
        return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
 | 
					        return [ReportLineItem(x) for x in JournalEntryLineItem.query
 | 
				
			||||||
 | 
					                .join(JournalEntry)
 | 
				
			||||||
                .filter(*conditions)
 | 
					                .filter(*conditions)
 | 
				
			||||||
                .order_by(Transaction.date,
 | 
					                .order_by(JournalEntry.date,
 | 
				
			||||||
                          JournalEntry.is_debit.desc(),
 | 
					                          JournalEntry.no,
 | 
				
			||||||
                          JournalEntry.no)
 | 
					                          JournalEntryLineItem.is_debit.desc(),
 | 
				
			||||||
                .options(selectinload(JournalEntry.transaction)).all()]
 | 
					                          JournalEntryLineItem.no)
 | 
				
			||||||
 | 
					                .options(selectinload(JournalEntryLineItem.journal_entry))
 | 
				
			||||||
 | 
					                .all()]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __get_total_entry(self) -> ReportEntry | None:
 | 
					    def __get_total(self) -> ReportLineItem | None:
 | 
				
			||||||
        """Composes the total entry.
 | 
					        """Composes the total line item.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The total entry, or None if there is no data.
 | 
					        :return: The total line item, or None if there is no data.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if self.brought_forward is None and len(self.entries) == 0:
 | 
					        if self.brought_forward is None and len(self.line_items) == 0:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        entry: ReportEntry = ReportEntry()
 | 
					        line_item: ReportLineItem = ReportLineItem()
 | 
				
			||||||
        entry.is_total = True
 | 
					        line_item.is_total = True
 | 
				
			||||||
        entry.summary = gettext("Total")
 | 
					        line_item.description = gettext("Total")
 | 
				
			||||||
        entry.debit = sum([x.debit for x in self.entries
 | 
					        line_item.debit = sum([x.debit for x in self.line_items
 | 
				
			||||||
                           if x.debit is not None])
 | 
					                               if x.debit is not None])
 | 
				
			||||||
        entry.credit = sum([x.credit for x in self.entries
 | 
					        line_item.credit = sum([x.credit for x in self.line_items
 | 
				
			||||||
                            if x.credit is not None])
 | 
					                                if x.credit is not None])
 | 
				
			||||||
        entry.balance = entry.debit - entry.credit
 | 
					        line_item.balance = line_item.debit - line_item.credit
 | 
				
			||||||
        if self.brought_forward is not None:
 | 
					        if self.brought_forward is not None:
 | 
				
			||||||
            entry.balance = self.brought_forward.balance + entry.balance
 | 
					            line_item.balance \
 | 
				
			||||||
        return entry
 | 
					                = self.brought_forward.balance + line_item.balance
 | 
				
			||||||
 | 
					        return line_item
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __populate_balance(self) -> None:
 | 
					    def __populate_balance(self) -> None:
 | 
				
			||||||
        """Populates the balance of the entries.
 | 
					        """Populates the balance of the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					        if self.__account.is_nominal:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
        balance: Decimal = 0 if self.brought_forward is None \
 | 
					        balance: Decimal = 0 if self.brought_forward is None \
 | 
				
			||||||
            else self.brought_forward.balance
 | 
					            else self.brought_forward.balance
 | 
				
			||||||
        for entry in self.entries:
 | 
					        for line_item in self.line_items:
 | 
				
			||||||
            if entry.debit is not None:
 | 
					            if line_item.debit is not None:
 | 
				
			||||||
                balance = balance + entry.debit
 | 
					                balance = balance + line_item.debit
 | 
				
			||||||
            if entry.credit is not None:
 | 
					            if line_item.credit is not None:
 | 
				
			||||||
                balance = balance - entry.credit
 | 
					                balance = balance - line_item.credit
 | 
				
			||||||
            entry.balance = balance
 | 
					            line_item.balance = balance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CSVRow(BaseCSVRow):
 | 
					class CSVRow(BaseCSVRow):
 | 
				
			||||||
    """A row in the CSV."""
 | 
					    """A row in the CSV."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, txn_date: date | str | None,
 | 
					    def __init__(self, journal_entry_date: date | str | None,
 | 
				
			||||||
                 summary: str | None,
 | 
					                 description: str | None,
 | 
				
			||||||
                 debit: str | Decimal | None,
 | 
					                 debit: str | Decimal | None,
 | 
				
			||||||
                 credit: str | Decimal | None,
 | 
					                 credit: str | Decimal | None,
 | 
				
			||||||
                 balance: str | Decimal | None,
 | 
					                 balance: str | Decimal | None,
 | 
				
			||||||
                 note: str | None):
 | 
					                 note: str | None):
 | 
				
			||||||
        """Constructs a row in the CSV.
 | 
					        """Constructs a row in the CSV.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param txn_date: The transaction date.
 | 
					        :param journal_entry_date: The journal entry date.
 | 
				
			||||||
        :param summary: The summary.
 | 
					        :param description: The description.
 | 
				
			||||||
        :param debit: The debit amount.
 | 
					        :param debit: The debit amount.
 | 
				
			||||||
        :param credit: The credit amount.
 | 
					        :param credit: The credit amount.
 | 
				
			||||||
        :param balance: The balance.
 | 
					        :param balance: The balance.
 | 
				
			||||||
        :param note: The note.
 | 
					        :param note: The note.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.date: date | str | None = txn_date
 | 
					        self.date: date | str | None = journal_entry_date
 | 
				
			||||||
        """The date."""
 | 
					        """The date."""
 | 
				
			||||||
        self.summary: str | None = summary
 | 
					        self.description: str | None = description
 | 
				
			||||||
        """The summary."""
 | 
					        """The description."""
 | 
				
			||||||
        self.debit: str | Decimal | None = debit
 | 
					        self.debit: str | Decimal | None = debit
 | 
				
			||||||
        """The debit amount."""
 | 
					        """The debit amount."""
 | 
				
			||||||
        self.credit: str | Decimal | None = credit
 | 
					        self.credit: str | Decimal | None = credit
 | 
				
			||||||
@@ -223,7 +233,7 @@ class CSVRow(BaseCSVRow):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The values of the row.
 | 
					        :return: The values of the row.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return [self.date, self.summary,
 | 
					        return [self.date, self.description,
 | 
				
			||||||
                self.debit, self.credit, self.balance, self.note]
 | 
					                self.debit, self.credit, self.balance, self.note]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -234,19 +244,19 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
                 account: Account,
 | 
					                 account: Account,
 | 
				
			||||||
                 period: Period,
 | 
					                 period: Period,
 | 
				
			||||||
                 has_data: bool,
 | 
					                 has_data: bool,
 | 
				
			||||||
                 pagination: Pagination[ReportEntry],
 | 
					                 pagination: Pagination[ReportLineItem],
 | 
				
			||||||
                 brought_forward: ReportEntry | None,
 | 
					                 brought_forward: ReportLineItem | None,
 | 
				
			||||||
                 entries: list[ReportEntry],
 | 
					                 line_items: list[ReportLineItem],
 | 
				
			||||||
                 total: ReportEntry | None):
 | 
					                 total: ReportLineItem | None):
 | 
				
			||||||
        """Constructs the HTML page parameters.
 | 
					        """Constructs the HTML page parameters.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param currency: The currency.
 | 
					        :param currency: The currency.
 | 
				
			||||||
        :param account: The account.
 | 
					        :param account: The account.
 | 
				
			||||||
        :param period: The period.
 | 
					        :param period: The period.
 | 
				
			||||||
        :param has_data: True if there is any data, or False otherwise.
 | 
					        :param has_data: True if there is any data, or False otherwise.
 | 
				
			||||||
        :param brought_forward: The brought-forward entry.
 | 
					        :param brought_forward: The brought-forward line item.
 | 
				
			||||||
        :param entries: The report entries.
 | 
					        :param line_items: The line items.
 | 
				
			||||||
        :param total: The total entry.
 | 
					        :param total: The total line item.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.currency: Currency = currency
 | 
					        self.currency: Currency = currency
 | 
				
			||||||
        """The currency."""
 | 
					        """The currency."""
 | 
				
			||||||
@@ -256,14 +266,14 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
        """The period."""
 | 
					        """The period."""
 | 
				
			||||||
        self.__has_data: bool = has_data
 | 
					        self.__has_data: bool = has_data
 | 
				
			||||||
        """True if there is any data, or False otherwise."""
 | 
					        """True if there is any data, or False otherwise."""
 | 
				
			||||||
        self.pagination: Pagination[ReportEntry] = pagination
 | 
					        self.pagination: Pagination[ReportLineItem] = pagination
 | 
				
			||||||
        """The pagination."""
 | 
					        """The pagination."""
 | 
				
			||||||
        self.brought_forward: ReportEntry | None = brought_forward
 | 
					        self.brought_forward: ReportLineItem | None = brought_forward
 | 
				
			||||||
        """The brought-forward entry."""
 | 
					        """The brought-forward line item."""
 | 
				
			||||||
        self.entries: list[ReportEntry] = entries
 | 
					        self.line_items: list[ReportLineItem] = line_items
 | 
				
			||||||
        """The entries."""
 | 
					        """The line items."""
 | 
				
			||||||
        self.total: ReportEntry | None = total
 | 
					        self.total: ReportLineItem | None = total
 | 
				
			||||||
        """The total entry."""
 | 
					        """The total line item."""
 | 
				
			||||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
					        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
				
			||||||
            lambda x: ledger_url(currency, account, x))
 | 
					            lambda x: ledger_url(currency, account, x))
 | 
				
			||||||
        """The period chooser."""
 | 
					        """The period chooser."""
 | 
				
			||||||
@@ -302,9 +312,10 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The account options.
 | 
					        :return: The account options.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        in_use: sa.Select = sa.Select(JournalEntry.account_id)\
 | 
					        in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
 | 
				
			||||||
            .filter(JournalEntry.currency_code == self.currency.code)\
 | 
					            .filter(be(JournalEntryLineItem.currency_code
 | 
				
			||||||
            .group_by(JournalEntry.account_id)
 | 
					                       == self.currency.code))\
 | 
				
			||||||
 | 
					            .group_by(JournalEntryLineItem.account_id)
 | 
				
			||||||
        return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
 | 
					        return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
 | 
				
			||||||
                           x.id == self.account.id)
 | 
					                           x.id == self.account.id)
 | 
				
			||||||
                for x in Account.query.filter(Account.id.in_(in_use))
 | 
					                for x in Account.query.filter(Account.id.in_(in_use))
 | 
				
			||||||
@@ -327,14 +338,15 @@ class Ledger(BaseReport):
 | 
				
			|||||||
        """The account."""
 | 
					        """The account."""
 | 
				
			||||||
        self.__period: Period = period
 | 
					        self.__period: Period = period
 | 
				
			||||||
        """The period."""
 | 
					        """The period."""
 | 
				
			||||||
        collector: EntryCollector = EntryCollector(
 | 
					        collector: LineItemCollector = LineItemCollector(
 | 
				
			||||||
            self.__currency, self.__account, self.__period)
 | 
					            self.__currency, self.__account, self.__period)
 | 
				
			||||||
        self.__brought_forward: ReportEntry | None = collector.brought_forward
 | 
					        self.__brought_forward: ReportLineItem | None \
 | 
				
			||||||
        """The brought-forward entry."""
 | 
					            = collector.brought_forward
 | 
				
			||||||
        self.__entries: list[ReportEntry] = collector.entries
 | 
					        """The brought-forward line item."""
 | 
				
			||||||
        """The report entries."""
 | 
					        self.__line_items: list[ReportLineItem] = collector.line_items
 | 
				
			||||||
        self.__total: ReportEntry | None = collector.total
 | 
					        """The line items."""
 | 
				
			||||||
        """The total entry."""
 | 
					        self.__total: ReportLineItem | None = collector.total
 | 
				
			||||||
 | 
					        """The total line item."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def csv(self) -> Response:
 | 
					    def csv(self) -> Response:
 | 
				
			||||||
        """Returns the report as CSV for download.
 | 
					        """Returns the report as CSV for download.
 | 
				
			||||||
@@ -351,19 +363,19 @@ class Ledger(BaseReport):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The CSV rows.
 | 
					        :return: The CSV rows.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
 | 
					        rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"),
 | 
				
			||||||
                                     gettext("Debit"), gettext("Credit"),
 | 
					                                     gettext("Debit"), gettext("Credit"),
 | 
				
			||||||
                                     gettext("Balance"), gettext("Note"))]
 | 
					                                     gettext("Balance"), gettext("Note"))]
 | 
				
			||||||
        if self.__brought_forward is not None:
 | 
					        if self.__brought_forward is not None:
 | 
				
			||||||
            rows.append(CSVRow(self.__brought_forward.date,
 | 
					            rows.append(CSVRow(self.__brought_forward.date,
 | 
				
			||||||
                               self.__brought_forward.summary,
 | 
					                               self.__brought_forward.description,
 | 
				
			||||||
                               self.__brought_forward.debit,
 | 
					                               self.__brought_forward.debit,
 | 
				
			||||||
                               self.__brought_forward.credit,
 | 
					                               self.__brought_forward.credit,
 | 
				
			||||||
                               self.__brought_forward.balance,
 | 
					                               self.__brought_forward.balance,
 | 
				
			||||||
                               None))
 | 
					                               None))
 | 
				
			||||||
        rows.extend([CSVRow(x.date, x.summary,
 | 
					        rows.extend([CSVRow(x.date, x.description,
 | 
				
			||||||
                            x.debit, x.credit, x.balance, x.note)
 | 
					                            x.debit, x.credit, x.balance, x.note)
 | 
				
			||||||
                     for x in self.__entries])
 | 
					                     for x in self.__line_items])
 | 
				
			||||||
        if self.__total is not None:
 | 
					        if self.__total is not None:
 | 
				
			||||||
            rows.append(CSVRow(gettext("Total"), None,
 | 
					            rows.append(CSVRow(gettext("Total"), None,
 | 
				
			||||||
                               self.__total.debit, self.__total.credit,
 | 
					                               self.__total.debit, self.__total.credit,
 | 
				
			||||||
@@ -375,31 +387,31 @@ class Ledger(BaseReport):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: The report as HTML.
 | 
					        :return: The report as HTML.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        all_entries: list[ReportEntry] = []
 | 
					        all_line_items: list[ReportLineItem] = []
 | 
				
			||||||
        if self.__brought_forward is not None:
 | 
					        if self.__brought_forward is not None:
 | 
				
			||||||
            all_entries.append(self.__brought_forward)
 | 
					            all_line_items.append(self.__brought_forward)
 | 
				
			||||||
        all_entries.extend(self.__entries)
 | 
					        all_line_items.extend(self.__line_items)
 | 
				
			||||||
        if self.__total is not None:
 | 
					        if self.__total is not None:
 | 
				
			||||||
            all_entries.append(self.__total)
 | 
					            all_line_items.append(self.__total)
 | 
				
			||||||
        pagination: Pagination[ReportEntry] \
 | 
					        pagination: Pagination[ReportLineItem] \
 | 
				
			||||||
            = Pagination[ReportEntry](all_entries)
 | 
					            = Pagination[ReportLineItem](all_line_items, is_reversed=True)
 | 
				
			||||||
        page_entries: list[ReportEntry] = pagination.list
 | 
					        page_line_items: list[ReportLineItem] = pagination.list
 | 
				
			||||||
        has_data: bool = len(page_entries) > 0
 | 
					        has_data: bool = len(page_line_items) > 0
 | 
				
			||||||
        brought_forward: ReportEntry | None = None
 | 
					        brought_forward: ReportLineItem | None = None
 | 
				
			||||||
        if len(page_entries) > 0 and page_entries[0].is_brought_forward:
 | 
					        if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
 | 
				
			||||||
            brought_forward = page_entries[0]
 | 
					            brought_forward = page_line_items[0]
 | 
				
			||||||
            page_entries = page_entries[1:]
 | 
					            page_line_items = page_line_items[1:]
 | 
				
			||||||
        total: ReportEntry | None = None
 | 
					        total: ReportLineItem | None = None
 | 
				
			||||||
        if len(page_entries) > 0 and page_entries[-1].is_total:
 | 
					        if len(page_line_items) > 0 and page_line_items[-1].is_total:
 | 
				
			||||||
            total = page_entries[-1]
 | 
					            total = page_line_items[-1]
 | 
				
			||||||
            page_entries = page_entries[:-1]
 | 
					            page_line_items = page_line_items[:-1]
 | 
				
			||||||
        params: PageParams = PageParams(currency=self.__currency,
 | 
					        params: PageParams = PageParams(currency=self.__currency,
 | 
				
			||||||
                                        account=self.__account,
 | 
					                                        account=self.__account,
 | 
				
			||||||
                                        period=self.__period,
 | 
					                                        period=self.__period,
 | 
				
			||||||
                                        has_data=has_data,
 | 
					                                        has_data=has_data,
 | 
				
			||||||
                                        pagination=pagination,
 | 
					                                        pagination=pagination,
 | 
				
			||||||
                                        brought_forward=brought_forward,
 | 
					                                        brought_forward=brought_forward,
 | 
				
			||||||
                                        entries=page_entries,
 | 
					                                        line_items=page_line_items,
 | 
				
			||||||
                                        total=total)
 | 
					                                        total=total)
 | 
				
			||||||
        return render_template("accounting/report/ledger.html",
 | 
					        return render_template("accounting/report/ledger.html",
 | 
				
			||||||
                               report=params)
 | 
					                               report=params)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,29 +26,30 @@ from sqlalchemy.orm import selectinload
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext
 | 
				
			||||||
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
 | 
					from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
 | 
				
			||||||
    Transaction, JournalEntry
 | 
					    JournalEntry, JournalEntryLineItem
 | 
				
			||||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
					from accounting.report.utils.base_page_params import BasePageParams
 | 
				
			||||||
from accounting.report.utils.base_report import BaseReport
 | 
					from accounting.report.utils.base_report import BaseReport
 | 
				
			||||||
from accounting.report.utils.csv_export import csv_download
 | 
					from accounting.report.utils.csv_export import csv_download
 | 
				
			||||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
					from accounting.report.utils.report_chooser import ReportChooser
 | 
				
			||||||
from accounting.report.utils.report_type import ReportType
 | 
					from accounting.report.utils.report_type import ReportType
 | 
				
			||||||
 | 
					from accounting.utils.cast import be
 | 
				
			||||||
from accounting.utils.pagination import Pagination
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
from accounting.utils.query import parse_query_keywords
 | 
					from accounting.utils.query import parse_query_keywords
 | 
				
			||||||
from .journal import get_csv_rows
 | 
					from .journal import get_csv_rows
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EntryCollector:
 | 
					class LineItemCollector:
 | 
				
			||||||
    """The report entry collector."""
 | 
					    """The line item collector."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self):
 | 
					    def __init__(self):
 | 
				
			||||||
        """Constructs the report entry collector."""
 | 
					        """Constructs the line item collector."""
 | 
				
			||||||
        self.entries: list[JournalEntry] = self.__query_entries()
 | 
					        self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
 | 
				
			||||||
        """The report entries."""
 | 
					        """The line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __query_entries(self) -> list[JournalEntry]:
 | 
					    def __query_line_items(self) -> list[JournalEntryLineItem]:
 | 
				
			||||||
        """Queries and returns the journal entries.
 | 
					        """Queries and returns the line items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The journal entries.
 | 
					        :return: The line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        keywords: list[str] = parse_query_keywords(request.args.get("q"))
 | 
					        keywords: list[str] = parse_query_keywords(request.args.get("q"))
 | 
				
			||||||
        if len(keywords) == 0:
 | 
					        if len(keywords) == 0:
 | 
				
			||||||
@@ -56,22 +57,28 @@ class EntryCollector:
 | 
				
			|||||||
        conditions: list[sa.BinaryExpression] = []
 | 
					        conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
        for k in keywords:
 | 
					        for k in keywords:
 | 
				
			||||||
            sub_conditions: list[sa.BinaryExpression] \
 | 
					            sub_conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
                = [JournalEntry.summary.contains(k),
 | 
					                = [JournalEntryLineItem.description.icontains(k),
 | 
				
			||||||
                   JournalEntry.account_id.in_(
 | 
					                   JournalEntryLineItem.account_id.in_(
 | 
				
			||||||
                       self.__get_account_condition(k)),
 | 
					                       self.__get_account_condition(k)),
 | 
				
			||||||
                   JournalEntry.currency_code.in_(
 | 
					                   JournalEntryLineItem.currency_code.in_(
 | 
				
			||||||
                       self.__get_currency_condition(k)),
 | 
					                       self.__get_currency_condition(k)),
 | 
				
			||||||
                   JournalEntry.transaction_id.in_(
 | 
					                   JournalEntryLineItem.journal_entry_id.in_(
 | 
				
			||||||
                       self.__get_transaction_condition(k))]
 | 
					                       self.__get_journal_entry_condition(k))]
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                sub_conditions.append(JournalEntry.amount == Decimal(k))
 | 
					                sub_conditions.append(
 | 
				
			||||||
 | 
					                    JournalEntryLineItem.amount == Decimal(k))
 | 
				
			||||||
            except ArithmeticError:
 | 
					            except ArithmeticError:
 | 
				
			||||||
                pass
 | 
					                pass
 | 
				
			||||||
            conditions.append(sa.or_(*sub_conditions))
 | 
					            conditions.append(sa.or_(*sub_conditions))
 | 
				
			||||||
        return JournalEntry.query.filter(*conditions)\
 | 
					        return JournalEntryLineItem.query.join(JournalEntry)\
 | 
				
			||||||
            .options(selectinload(JournalEntry.account),
 | 
					            .filter(*conditions)\
 | 
				
			||||||
                     selectinload(JournalEntry.currency),
 | 
					            .order_by(JournalEntry.date,
 | 
				
			||||||
                     selectinload(JournalEntry.transaction)).all()
 | 
					                      JournalEntry.no,
 | 
				
			||||||
 | 
					                      JournalEntryLineItem.is_debit,
 | 
				
			||||||
 | 
					                      JournalEntryLineItem.no)\
 | 
				
			||||||
 | 
					            .options(selectinload(JournalEntryLineItem.account),
 | 
				
			||||||
 | 
					                     selectinload(JournalEntryLineItem.currency),
 | 
				
			||||||
 | 
					                     selectinload(JournalEntryLineItem.journal_entry)).all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def __get_account_condition(k: str) -> sa.Select:
 | 
					    def __get_account_condition(k: str) -> sa.Select:
 | 
				
			||||||
@@ -85,14 +92,14 @@ class EntryCollector:
 | 
				
			|||||||
                             sa.func.char_length(sa.cast(Account.no,
 | 
					                             sa.func.char_length(sa.cast(Account.no,
 | 
				
			||||||
                                                         sa.String)) + 1)
 | 
					                                                         sa.String)) + 1)
 | 
				
			||||||
        select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
 | 
					        select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
 | 
				
			||||||
            .filter(AccountL10n.title.contains(k))
 | 
					            .filter(AccountL10n.title.icontains(k))
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [Account.base_code.contains(k),
 | 
					            = [Account.base_code.contains(k),
 | 
				
			||||||
               Account.title_l10n.contains(k),
 | 
					               Account.title_l10n.icontains(k),
 | 
				
			||||||
               code.contains(k),
 | 
					               code.contains(k),
 | 
				
			||||||
               Account.id.in_(select_l10n)]
 | 
					               Account.id.in_(select_l10n)]
 | 
				
			||||||
        if k in gettext("Need offset"):
 | 
					        if k in gettext("Needs Offset"):
 | 
				
			||||||
            conditions.append(Account.is_offset_needed)
 | 
					            conditions.append(Account.is_need_offset)
 | 
				
			||||||
        return sa.select(Account.id).filter(sa.or_(*conditions))
 | 
					        return sa.select(Account.id).filter(sa.or_(*conditions))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
@@ -103,57 +110,63 @@ class EntryCollector:
 | 
				
			|||||||
        :return: The condition to filter the currency.
 | 
					        :return: The condition to filter the currency.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
 | 
					        select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
 | 
				
			||||||
            .filter(CurrencyL10n.name.contains(k))
 | 
					            .filter(CurrencyL10n.name.icontains(k))
 | 
				
			||||||
        return sa.select(Currency.code).filter(
 | 
					        return sa.select(Currency.code).filter(
 | 
				
			||||||
            sa.or_(Currency.code.contains(k),
 | 
					            sa.or_(Currency.code.icontains(k),
 | 
				
			||||||
                   Currency.name_l10n.contains(k),
 | 
					                   Currency.name_l10n.icontains(k),
 | 
				
			||||||
                   Currency.code.in_(select_l10n)))
 | 
					                   Currency.code.in_(select_l10n)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def __get_transaction_condition(k: str) -> sa.Select:
 | 
					    def __get_journal_entry_condition(k: str) -> sa.Select:
 | 
				
			||||||
        """Composes and returns the condition to filter the transaction.
 | 
					        """Composes and returns the condition to filter the journal entry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param k: The keyword.
 | 
					        :param k: The keyword.
 | 
				
			||||||
        :return: The condition to filter the transaction.
 | 
					        :return: The condition to filter the journal entry.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
        txn_date: datetime
 | 
					            = [JournalEntry.note.icontains(k)]
 | 
				
			||||||
 | 
					        journal_entry_date: datetime
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            txn_date = datetime.strptime(k, "%Y")
 | 
					            journal_entry_date = datetime.strptime(k, "%Y")
 | 
				
			||||||
            conditions.append(
 | 
					            conditions.append(
 | 
				
			||||||
                sa.extract("year", Transaction.date) == txn_date.year)
 | 
					                be(sa.extract("year", JournalEntry.date)
 | 
				
			||||||
 | 
					                   == journal_entry_date.year))
 | 
				
			||||||
        except ValueError:
 | 
					        except ValueError:
 | 
				
			||||||
            pass
 | 
					            pass
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            txn_date = datetime.strptime(k, "%Y/%m")
 | 
					            journal_entry_date = datetime.strptime(k, "%Y/%m")
 | 
				
			||||||
            conditions.append(sa.and_(
 | 
					            conditions.append(sa.and_(
 | 
				
			||||||
                sa.extract("year", Transaction.date) == txn_date.year,
 | 
					                sa.extract("year", JournalEntry.date)
 | 
				
			||||||
                sa.extract("month", Transaction.date) == txn_date.month))
 | 
					                == journal_entry_date.year,
 | 
				
			||||||
 | 
					                sa.extract("month", JournalEntry.date)
 | 
				
			||||||
 | 
					                == journal_entry_date.month))
 | 
				
			||||||
        except ValueError:
 | 
					        except ValueError:
 | 
				
			||||||
            pass
 | 
					            pass
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
 | 
					            journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
 | 
				
			||||||
            conditions.append(sa.and_(
 | 
					            conditions.append(sa.and_(
 | 
				
			||||||
                sa.extract("month", Transaction.date) == txn_date.month,
 | 
					                sa.extract("month", JournalEntry.date)
 | 
				
			||||||
                sa.extract("day", Transaction.date) == txn_date.day))
 | 
					                == journal_entry_date.month,
 | 
				
			||||||
 | 
					                sa.extract("day", JournalEntry.date)
 | 
				
			||||||
 | 
					                == journal_entry_date.day))
 | 
				
			||||||
        except ValueError:
 | 
					        except ValueError:
 | 
				
			||||||
            pass
 | 
					            pass
 | 
				
			||||||
        return sa.select(Transaction.id).filter(sa.or_(*conditions))
 | 
					        return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PageParams(BasePageParams):
 | 
					class PageParams(BasePageParams):
 | 
				
			||||||
    """The HTML page parameters."""
 | 
					    """The HTML page parameters."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, pagination: Pagination[JournalEntry],
 | 
					    def __init__(self, pagination: Pagination[JournalEntryLineItem],
 | 
				
			||||||
                 entries: list[JournalEntry]):
 | 
					                 line_items: list[JournalEntryLineItem]):
 | 
				
			||||||
        """Constructs the HTML page parameters.
 | 
					        """Constructs the HTML page parameters.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param entries: The search result entries.
 | 
					        :param line_items: The search result line items.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.pagination: Pagination[JournalEntry] = pagination
 | 
					        self.pagination: Pagination[JournalEntryLineItem] = pagination
 | 
				
			||||||
        """The pagination."""
 | 
					        """The pagination."""
 | 
				
			||||||
        self.entries: list[JournalEntry] = entries
 | 
					        self.line_items: list[JournalEntryLineItem] = line_items
 | 
				
			||||||
        """The entries."""
 | 
					        """The line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def has_data(self) -> bool:
 | 
					    def has_data(self) -> bool:
 | 
				
			||||||
@@ -161,7 +174,7 @@ class PageParams(BasePageParams):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: True if there is any data, or False otherwise.
 | 
					        :return: True if there is any data, or False otherwise.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return len(self.entries) > 0
 | 
					        return len(self.line_items) > 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def report_chooser(self) -> ReportChooser:
 | 
					    def report_chooser(self) -> ReportChooser:
 | 
				
			||||||
@@ -177,8 +190,9 @@ class Search(BaseReport):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self):
 | 
					    def __init__(self):
 | 
				
			||||||
        """Constructs a search."""
 | 
					        """Constructs a search."""
 | 
				
			||||||
        self.__entries: list[JournalEntry] = EntryCollector().entries
 | 
					        self.__line_items: list[JournalEntryLineItem] \
 | 
				
			||||||
        """The journal entries."""
 | 
					            = LineItemCollector().line_items
 | 
				
			||||||
 | 
					        """The line items."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def csv(self) -> Response:
 | 
					    def csv(self) -> Response:
 | 
				
			||||||
        """Returns the report as CSV for download.
 | 
					        """Returns the report as CSV for download.
 | 
				
			||||||
@@ -186,16 +200,17 @@ class Search(BaseReport):
 | 
				
			|||||||
        :return: The response of the report for download.
 | 
					        :return: The response of the report for download.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        filename: str = "search-{q}.csv".format(q=request.args["q"])
 | 
					        filename: str = "search-{q}.csv".format(q=request.args["q"])
 | 
				
			||||||
        return csv_download(filename, get_csv_rows(self.__entries))
 | 
					        return csv_download(filename, get_csv_rows(self.__line_items))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def html(self) -> str:
 | 
					    def html(self) -> str:
 | 
				
			||||||
        """Composes and returns the report as HTML.
 | 
					        """Composes and returns the report as HTML.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The report as HTML.
 | 
					        :return: The report as HTML.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        pagination: Pagination[JournalEntry] \
 | 
					        pagination: Pagination[JournalEntryLineItem] \
 | 
				
			||||||
            = Pagination[JournalEntry](self.__entries)
 | 
					            = Pagination[JournalEntryLineItem](self.__line_items,
 | 
				
			||||||
 | 
					                                               is_reversed=True)
 | 
				
			||||||
        params: PageParams = PageParams(pagination=pagination,
 | 
					        params: PageParams = PageParams(pagination=pagination,
 | 
				
			||||||
                                        entries=pagination.list)
 | 
					                                        line_items=pagination.list)
 | 
				
			||||||
        return render_template("accounting/report/search.html",
 | 
					        return render_template("accounting/report/search.html",
 | 
				
			||||||
                               report=params)
 | 
					                               report=params)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,7 +24,8 @@ from flask import Response, render_template
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext
 | 
				
			||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
 | 
					from accounting.models import Currency, Account, JournalEntry, \
 | 
				
			||||||
 | 
					    JournalEntryLineItem
 | 
				
			||||||
from accounting.report.period import Period, PeriodChooser
 | 
					from accounting.report.period import Period, PeriodChooser
 | 
				
			||||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
					from accounting.report.utils.base_page_params import BasePageParams
 | 
				
			||||||
from accounting.report.utils.base_report import BaseReport
 | 
					from accounting.report.utils.base_report import BaseReport
 | 
				
			||||||
@@ -178,16 +179,16 @@ class TrialBalance(BaseReport):
 | 
				
			|||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        conditions: list[sa.BinaryExpression] \
 | 
					        conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
            = [JournalEntry.currency_code == self.__currency.code]
 | 
					            = [JournalEntryLineItem.currency_code == self.__currency.code]
 | 
				
			||||||
        if self.__period.start is not None:
 | 
					        if self.__period.start is not None:
 | 
				
			||||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
					            conditions.append(JournalEntry.date >= self.__period.start)
 | 
				
			||||||
        if self.__period.end is not None:
 | 
					        if self.__period.end is not None:
 | 
				
			||||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
					            conditions.append(JournalEntry.date <= self.__period.end)
 | 
				
			||||||
        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
					        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
				
			||||||
            (JournalEntry.is_debit, JournalEntry.amount),
 | 
					            (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
 | 
				
			||||||
            else_=-JournalEntry.amount)).label("balance")
 | 
					            else_=-JournalEntryLineItem.amount)).label("balance")
 | 
				
			||||||
        select_balances: sa.Select = sa.select(Account.id, balance_func)\
 | 
					        select_balances: sa.Select = sa.select(Account.id, balance_func)\
 | 
				
			||||||
            .join(Transaction).join(Account)\
 | 
					            .join(JournalEntry).join(Account)\
 | 
				
			||||||
            .filter(*conditions)\
 | 
					            .filter(*conditions)\
 | 
				
			||||||
            .group_by(Account.id)\
 | 
					            .group_by(Account.id)\
 | 
				
			||||||
            .order_by(Account.base_code, Account.no)
 | 
					            .order_by(Account.base_code, Account.no)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,8 +26,8 @@ import sqlalchemy as sa
 | 
				
			|||||||
from flask import request
 | 
					from flask import request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounting import db
 | 
					from accounting import db
 | 
				
			||||||
from accounting.models import Currency, JournalEntry
 | 
					from accounting.models import Currency, JournalEntryLineItem
 | 
				
			||||||
from accounting.utils.txn_types import TransactionType
 | 
					from accounting.utils.journal_entry_types import JournalEntryType
 | 
				
			||||||
from .option_link import OptionLink
 | 
					from .option_link import OptionLink
 | 
				
			||||||
from .report_chooser import ReportChooser
 | 
					from .report_chooser import ReportChooser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -52,12 +52,12 @@ class BasePageParams(ABC):
 | 
				
			|||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def txn_types(self) -> t.Type[TransactionType]:
 | 
					    def journal_entry_types(self) -> t.Type[JournalEntryType]:
 | 
				
			||||||
        """Returns the transaction types.
 | 
					        """Returns the journal entry types.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The transaction types.
 | 
					        :return: The journal entry types.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return TransactionType
 | 
					        return JournalEntryType
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def csv_uri(self) -> str:
 | 
					    def csv_uri(self) -> str:
 | 
				
			||||||
@@ -81,8 +81,8 @@ class BasePageParams(ABC):
 | 
				
			|||||||
        :return: The currency options.
 | 
					        :return: The currency options.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        in_use: set[str] = set(db.session.scalars(
 | 
					        in_use: set[str] = set(db.session.scalars(
 | 
				
			||||||
            sa.select(JournalEntry.currency_code)
 | 
					            sa.select(JournalEntryLineItem.currency_code)
 | 
				
			||||||
            .group_by(JournalEntry.currency_code)).all())
 | 
					            .group_by(JournalEntryLineItem.currency_code)).all())
 | 
				
			||||||
        return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
 | 
					        return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
 | 
				
			||||||
                for x in Currency.query.filter(Currency.code.in_(in_use))
 | 
					                for x in Currency.query.filter(Currency.code.in_(in_use))
 | 
				
			||||||
                .order_by(Currency.code).all()]
 | 
					                .order_by(Currency.code).all()]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -71,8 +71,8 @@ def default_ie_account_code() -> str:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    :return: The default account code for the income and expenses log.
 | 
					    :return: The default account code for the income and expenses log.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    with current_app.app_context():
 | 
					    return current_app.config.get("ACCOUNTING_DEFAULT_IE_ACCOUNT",
 | 
				
			||||||
        return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
 | 
					                                  Account.CASH_CODE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def default_ie_account() -> IncomeExpensesAccount:
 | 
					def default_ie_account() -> IncomeExpensesAccount:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,10 +27,15 @@ class OptionLink:
 | 
				
			|||||||
        """Constructs an option link.
 | 
					        """Constructs an option link.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param title: The title.
 | 
					        :param title: The title.
 | 
				
			||||||
        :param url: The URI.
 | 
					        :param url: The URL.
 | 
				
			||||||
        :param is_active: True if active, or False otherwise
 | 
					        :param is_active: True if active, or False otherwise
 | 
				
			||||||
 | 
					        :param fa_icon: The font-awesome icon, if any.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.title: str = title
 | 
					        self.title: str = title
 | 
				
			||||||
 | 
					        """The title."""
 | 
				
			||||||
        self.url: str = url
 | 
					        self.url: str = url
 | 
				
			||||||
 | 
					        """The URL."""
 | 
				
			||||||
        self.is_active: bool = is_active
 | 
					        self.is_active: bool = is_active
 | 
				
			||||||
 | 
					        """True if active, or False otherwise."""
 | 
				
			||||||
        self.fa_icon: str | None = fa_icon
 | 
					        self.fa_icon: str | None = fa_icon
 | 
				
			||||||
 | 
					        """The font-awesome icon, if any."""
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,6 +31,9 @@
 | 
				
			|||||||
    color: #141619;
 | 
					    color: #141619;
 | 
				
			||||||
    background-color: #D3D3D4;
 | 
					    background-color: #D3D3D4;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					.form-control.accounting-disabled {
 | 
				
			||||||
 | 
					    background-color: #e9ecef;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** The toolbar */
 | 
					/** The toolbar */
 | 
				
			||||||
.accounting-toolbar {
 | 
					.accounting-toolbar {
 | 
				
			||||||
@@ -69,11 +72,14 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@media(max-width:767px) {
 | 
					@media(max-width:767px) {
 | 
				
			||||||
 | 
					    .accounting-toolbar {
 | 
				
			||||||
 | 
					    	width: 100%;
 | 
				
			||||||
 | 
					    	justify-content: space-evenly;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    .accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
 | 
					    .accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
 | 
				
			||||||
        height: 3.2rem;
 | 
					        height: 3.2rem;
 | 
				
			||||||
        width: 3.2rem;
 | 
					        width: 3.2rem;
 | 
				
			||||||
        border-radius: 50%;
 | 
					        border-radius: 50%;
 | 
				
			||||||
        margin-left: 1rem;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    .accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
 | 
					    .accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
 | 
				
			||||||
        padding-top: 0.7rem;
 | 
					        padding-top: 0.7rem;
 | 
				
			||||||
@@ -113,46 +119,70 @@
 | 
				
			|||||||
    border-bottom: thick double slategray;
 | 
					    border-bottom: thick double slategray;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Links between objects */
 | 
				
			||||||
 | 
					.accounting-original-line-item {
 | 
				
			||||||
 | 
					    border-top: thin solid darkslategray;
 | 
				
			||||||
 | 
					    padding: 0.2rem 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-original-line-item a {
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					    text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-original-line-item a:hover {
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-offset-line-items {
 | 
				
			||||||
 | 
					    border-top: thin solid darkslategray;
 | 
				
			||||||
 | 
					    padding: 0.2rem 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-offset-line-items ul li {
 | 
				
			||||||
 | 
					    list-style: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-offset-line-items ul li a {
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					    text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-offset-line-items ul li a:hover {
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** The option selector */
 | 
					/** The option selector */
 | 
				
			||||||
.accounting-selector-list {
 | 
					.accounting-selector-list {
 | 
				
			||||||
    height: 20rem;
 | 
					    height: 20rem;
 | 
				
			||||||
    overflow-y: scroll;
 | 
					    overflow-y: scroll;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** The transaction management */
 | 
					/** The journal entry management */
 | 
				
			||||||
.accounting-currency-control {
 | 
					.accounting-currency-control {
 | 
				
			||||||
    background-color: transparent;
 | 
					    background-color: transparent;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-currency-content {
 | 
					.accounting-currency-content {
 | 
				
			||||||
    width: calc(100% - 3rem);
 | 
					    width: calc(100% - 3rem);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-entry-content {
 | 
					.accounting-line-item-content {
 | 
				
			||||||
    width: calc(100% - 3rem);
 | 
					    width: calc(100% - 3rem);
 | 
				
			||||||
    background-color: transparent;
 | 
					    background-color: transparent;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-entry-control {
 | 
					 | 
				
			||||||
    border-color: transparent;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
 | 
					.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
 | 
				
			||||||
    background-color: #f2f2f2;
 | 
					    background-color: #f2f2f2;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
 | 
					 | 
				
			||||||
    background-color: #c7dbd2;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.accounting-list-group-hover .list-group-item:hover {
 | 
					.accounting-list-group-hover .list-group-item:hover {
 | 
				
			||||||
    background-color: #ececec;
 | 
					    background-color: #ececec;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-transaction-entry {
 | 
					.accounting-journal-entry-line-item {
 | 
				
			||||||
    border: none;
 | 
					    border: none;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-transaction-entry-header {
 | 
					.accounting-journal-entry-line-item-header {
 | 
				
			||||||
    font-weight: bolder;
 | 
					    font-weight: bolder;
 | 
				
			||||||
    border-bottom: thick double slategray;
 | 
					    border-bottom: thick double slategray;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.list-group-item.accounting-transaction-entry-total {
 | 
					.list-group-item.accounting-journal-entry-line-item-total {
 | 
				
			||||||
    font-weight: bolder;
 | 
					    font-weight: bolder;
 | 
				
			||||||
    border-top: thick double slategray;
 | 
					    border-top: thick double slategray;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					.accounting-line-item-editor-original-line-item-content {
 | 
				
			||||||
 | 
					    width: calc(100% - 3rem);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* The report table */
 | 
					/* The report table */
 | 
				
			||||||
.accounting-report-table-header, .accounting-report-table-footer {
 | 
					.accounting-report-table-header, .accounting-report-table-footer {
 | 
				
			||||||
@@ -191,12 +221,18 @@ a.accounting-report-table-row {
 | 
				
			|||||||
.accounting-journal-table .accounting-report-table-row {
 | 
					.accounting-journal-table .accounting-report-table-row {
 | 
				
			||||||
    grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
 | 
					    grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-ledger-table .accounting-report-table-row {
 | 
					.accounting-ledger-real-table .accounting-report-table-row {
 | 
				
			||||||
    grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
 | 
					    grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row {
 | 
					.accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
 | 
				
			||||||
    grid-template-columns: 5fr 1fr 1fr 1fr;
 | 
					    grid-template-columns: 5fr 1fr 1fr 1fr;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					.accounting-ledger-nominal-table .accounting-report-table-row {
 | 
				
			||||||
 | 
					    grid-template-columns: 1fr 4fr 1fr 1fr;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row {
 | 
				
			||||||
 | 
					    grid-template-columns: 5fr 1fr 1fr;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
.accounting-income-expenses-table .accounting-report-table-row {
 | 
					.accounting-income-expenses-table .accounting-report-table-row {
 | 
				
			||||||
    grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
 | 
					    grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -282,6 +318,22 @@ a.accounting-report-table-row {
 | 
				
			|||||||
    padding-left: 1rem;
 | 
					    padding-left: 1rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* The description editor */
 | 
				
			||||||
 | 
					.accounting-description-editor-buttons .btn {
 | 
				
			||||||
 | 
					    margin-bottom: 0.3rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* The order of the journal entries in a same day */
 | 
				
			||||||
 | 
					.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					    text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-journal-entry-order-item-currency {
 | 
				
			||||||
 | 
					    margin-left: 0.5rem;
 | 
				
			||||||
 | 
					    border-top: thin solid lightgray;
 | 
				
			||||||
 | 
					    margin-top: 0.2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* The Material Design text field (floating form control in Bootstrap) */
 | 
					/* The Material Design text field (floating form control in Bootstrap) */
 | 
				
			||||||
.accounting-material-text-field {
 | 
					.accounting-material-text-field {
 | 
				
			||||||
    position: relative;
 | 
					    position: relative;
 | 
				
			||||||
@@ -306,7 +358,7 @@ a.accounting-report-table-row {
 | 
				
			|||||||
.accounting-material-fab {
 | 
					.accounting-material-fab {
 | 
				
			||||||
    position: fixed;
 | 
					    position: fixed;
 | 
				
			||||||
    right: 2rem;
 | 
					    right: 2rem;
 | 
				
			||||||
    bottom: 1rem;
 | 
					    bottom: 2rem;
 | 
				
			||||||
    z-index: 10;
 | 
					    z-index: 10;
 | 
				
			||||||
    flex-direction: column-reverse;
 | 
					    flex-direction: column-reverse;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,161 +24,335 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Initializes the page JavaScript.
 | 
					// Initializes the page JavaScript.
 | 
				
			||||||
document.addEventListener("DOMContentLoaded", () => {
 | 
					document.addEventListener("DOMContentLoaded", () => {
 | 
				
			||||||
    initializeBaseAccountSelector();
 | 
					    AccountForm.initialize();
 | 
				
			||||||
    document.getElementById("accounting-base-code")
 | 
					 | 
				
			||||||
        .onchange = validateBase;
 | 
					 | 
				
			||||||
    document.getElementById("accounting-title")
 | 
					 | 
				
			||||||
        .onchange = validateTitle;
 | 
					 | 
				
			||||||
    document.getElementById("accounting-form")
 | 
					 | 
				
			||||||
        .onsubmit = validateForm;
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Initializes the base account selector.
 | 
					 * The account form.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * @private
 | 
					 * @private
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
function initializeBaseAccountSelector() {
 | 
					class AccountForm {
 | 
				
			||||||
    const selector = document.getElementById("accounting-base-selector-modal");
 | 
					
 | 
				
			||||||
    const base = document.getElementById("accounting-base");
 | 
					    /**
 | 
				
			||||||
    const baseCode = document.getElementById("accounting-base-code");
 | 
					     * The base account selector
 | 
				
			||||||
    const baseContent = document.getElementById("accounting-base-content");
 | 
					     * @type {BaseAccountSelector}
 | 
				
			||||||
    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
					     */
 | 
				
			||||||
    const btnClear = document.getElementById("accounting-btn-clear-base");
 | 
					    #baseAccountSelector;
 | 
				
			||||||
    selector.addEventListener("show.bs.modal", () => {
 | 
					
 | 
				
			||||||
        base.classList.add("accounting-not-empty");
 | 
					    /**
 | 
				
			||||||
        for (const option of options) {
 | 
					     * The form element
 | 
				
			||||||
            option.classList.remove("active");
 | 
					     * @type {HTMLFormElement}
 | 
				
			||||||
        }
 | 
					     */
 | 
				
			||||||
        const selected = document.getElementById("accounting-base-option-" + baseCode.value);
 | 
					    #formElement;
 | 
				
			||||||
        if (selected !== null) {
 | 
					
 | 
				
			||||||
            selected.classList.add("active");
 | 
					    /**
 | 
				
			||||||
        }
 | 
					     * The control of the base account
 | 
				
			||||||
    });
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
    selector.addEventListener("hidden.bs.modal", () => {
 | 
					     */
 | 
				
			||||||
        if (baseCode.value === "") {
 | 
					    #baseControl;
 | 
				
			||||||
            base.classList.remove("accounting-not-empty");
 | 
					
 | 
				
			||||||
        }
 | 
					    /**
 | 
				
			||||||
    });
 | 
					     * The input of the base account
 | 
				
			||||||
    for (const option of options) {
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
        option.onclick = () => {
 | 
					     */
 | 
				
			||||||
            baseCode.value = option.dataset.code;
 | 
					    #baseCode;
 | 
				
			||||||
            baseContent.innerText = option.dataset.content;
 | 
					
 | 
				
			||||||
            btnClear.classList.add("btn-danger");
 | 
					    /**
 | 
				
			||||||
            btnClear.classList.remove("btn-secondary")
 | 
					     * The base account
 | 
				
			||||||
            btnClear.disabled = false;
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
            validateBase();
 | 
					     */
 | 
				
			||||||
            bootstrap.Modal.getInstance(selector).hide();
 | 
					    #base;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message for the base account
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #baseError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The title
 | 
				
			||||||
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #title;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message of the title
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #titleError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The control of the is-need-offset option
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #isNeedOffsetControl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The is-need-offset option
 | 
				
			||||||
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #isNeedOffset;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Constructs the account form.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        this.#baseAccountSelector = new BaseAccountSelector(this);
 | 
				
			||||||
 | 
					        this.#formElement = document.getElementById("accounting-form");
 | 
				
			||||||
 | 
					        this.#baseControl = document.getElementById("accounting-base-control");
 | 
				
			||||||
 | 
					        this.#baseCode = document.getElementById("accounting-base-code");
 | 
				
			||||||
 | 
					        this.#base = document.getElementById("accounting-base");
 | 
				
			||||||
 | 
					        this.#baseError = document.getElementById("accounting-base-error");
 | 
				
			||||||
 | 
					        this.#title = document.getElementById("accounting-title");
 | 
				
			||||||
 | 
					        this.#titleError = document.getElementById("accounting-title-error");
 | 
				
			||||||
 | 
					        this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
 | 
				
			||||||
 | 
					        this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
 | 
				
			||||||
 | 
					        this.#formElement.onsubmit = () => {
 | 
				
			||||||
 | 
					            return this.#validateForm();
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        this.#baseControl.onclick = () => {
 | 
				
			||||||
 | 
					            this.#baseControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					            this.#baseAccountSelector.onOpen(this.#baseCode.value);
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    btnClear.onclick = () => {
 | 
					
 | 
				
			||||||
        baseCode.value = "";
 | 
					    /**
 | 
				
			||||||
        baseContent.innerText = "";
 | 
					     * The callback when the base account selector is closed.
 | 
				
			||||||
        btnClear.classList.add("btn-secondary")
 | 
					     *
 | 
				
			||||||
        btnClear.classList.remove("btn-danger");
 | 
					     */
 | 
				
			||||||
        btnClear.disabled = true;
 | 
					    onBaseAccountSelectorClosed() {
 | 
				
			||||||
        validateBase();
 | 
					        if (this.#baseCode.value === "") {
 | 
				
			||||||
        bootstrap.Modal.getInstance(selector).hide();
 | 
					            this.#baseControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Sets the base account.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param code {string} the base account code
 | 
				
			||||||
 | 
					     * @param text {string} the text for the base account
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    setBaseAccount(code, text) {
 | 
				
			||||||
 | 
					        this.#baseCode.value = code;
 | 
				
			||||||
 | 
					        this.#base.innerText = text;
 | 
				
			||||||
 | 
					        if (["1", "2", "3"].includes(code.substring(0, 1))) {
 | 
				
			||||||
 | 
					            this.#isNeedOffsetControl.classList.remove("d-none");
 | 
				
			||||||
 | 
					            this.#isNeedOffset.disabled = false;
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#isNeedOffsetControl.classList.add("d-none");
 | 
				
			||||||
 | 
					            this.#isNeedOffset.disabled = true;
 | 
				
			||||||
 | 
					            this.#isNeedOffset.checked = false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#validateBase();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Clears the base account.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    clearBaseAccount() {
 | 
				
			||||||
 | 
					        this.#baseCode.value = "";
 | 
				
			||||||
 | 
					        this.#base.innerText = "";
 | 
				
			||||||
 | 
					        this.#validateBase();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the form.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validateForm() {
 | 
				
			||||||
 | 
					        let isValid = true;
 | 
				
			||||||
 | 
					        isValid = this.#validateBase() && isValid;
 | 
				
			||||||
 | 
					        isValid = this.#validateTitle() && isValid;
 | 
				
			||||||
 | 
					        return isValid;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the base account.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validateBase() {
 | 
				
			||||||
 | 
					        if (this.#baseCode.value === "") {
 | 
				
			||||||
 | 
					            this.#baseControl.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#baseError.innerText = A_("Please select the base account.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#baseControl.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.#baseError.innerText = "";
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the title.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validateTitle() {
 | 
				
			||||||
 | 
					        this.#title.value = this.#title.value.trim();
 | 
				
			||||||
 | 
					        if (this.#title.value === "") {
 | 
				
			||||||
 | 
					            this.#title.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#titleError.innerText = A_("Please fill in the title.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#title.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.#titleError.innerText = "";
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The account form
 | 
				
			||||||
 | 
					     * @type {AccountForm} the form
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static #form;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static initialize() {
 | 
				
			||||||
 | 
					        this.#form = new AccountForm();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    initializeBaseAccountQuery();
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Initializes the query on the base account options.
 | 
					 * The base account selector.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * @private
 | 
					 * @private
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
function initializeBaseAccountQuery() {
 | 
					class BaseAccountSelector {
 | 
				
			||||||
    const query = document.getElementById("accounting-base-selector-query");
 | 
					
 | 
				
			||||||
    const optionList = document.getElementById("accounting-base-option-list");
 | 
					    /**
 | 
				
			||||||
    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
					     * The account form
 | 
				
			||||||
    const queryNoResult = document.getElementById("accounting-base-option-no-result");
 | 
					     * @type {AccountForm}
 | 
				
			||||||
    query.addEventListener("input", () => {
 | 
					     */
 | 
				
			||||||
        if (query.value === "") {
 | 
					    #form;
 | 
				
			||||||
            for (const option of options) {
 | 
					
 | 
				
			||||||
                option.classList.remove("d-none");
 | 
					    /**
 | 
				
			||||||
            }
 | 
					     * The selector modal
 | 
				
			||||||
            optionList.classList.remove("d-none");
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
            queryNoResult.classList.add("d-none");
 | 
					     */
 | 
				
			||||||
            return
 | 
					    #modal;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The query input
 | 
				
			||||||
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #query;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message when the query has no result
 | 
				
			||||||
 | 
					     * @type {HTMLParagraphElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #queryNoResult;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The option list
 | 
				
			||||||
 | 
					     * @type {HTMLUListElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #optionList;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The options
 | 
				
			||||||
 | 
					     * @type {HTMLLIElement[]}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #options;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The button to clear the base account value
 | 
				
			||||||
 | 
					     * @type {HTMLButtonElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #clearButton;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Constructs the base account selector.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param form {AccountForm} the form
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    constructor(form) {
 | 
				
			||||||
 | 
					        this.#form = form;
 | 
				
			||||||
 | 
					        this.#modal = document.getElementById("accounting-base-selector-modal");
 | 
				
			||||||
 | 
					        this.#query = document.getElementById("accounting-base-selector-query");
 | 
				
			||||||
 | 
					        this.#optionList = document.getElementById("accounting-base-selector-option-list");
 | 
				
			||||||
 | 
					        // noinspection JSValidateTypes
 | 
				
			||||||
 | 
					        this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
 | 
				
			||||||
 | 
					        this.#clearButton = document.getElementById("accounting-base-selector-clear");
 | 
				
			||||||
 | 
					        this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
 | 
				
			||||||
 | 
					        this.#modal.addEventListener("hidden.bs.modal", () => {
 | 
				
			||||||
 | 
					            this.#form.onBaseAccountSelectorClosed();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
 | 
					            option.onclick = () => {
 | 
				
			||||||
 | 
					                this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        let hasAnyMatched = false;
 | 
					        this.#clearButton.onclick = () => {
 | 
				
			||||||
        for (const option of options) {
 | 
					            this.#form.clearBaseAccount();
 | 
				
			||||||
            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
					        };
 | 
				
			||||||
            let isMatched = false;
 | 
					        this.#initializeBaseAccountQuery();
 | 
				
			||||||
            for (const queryValue of queryValues) {
 | 
					    }
 | 
				
			||||||
                if (queryValue.includes(query.value)) {
 | 
					
 | 
				
			||||||
                    isMatched = true;
 | 
					    /**
 | 
				
			||||||
                    break;
 | 
					     * Initializes the query.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #initializeBaseAccountQuery() {
 | 
				
			||||||
 | 
					        this.#query.addEventListener("input", () => {
 | 
				
			||||||
 | 
					            if (this.#query.value === "") {
 | 
				
			||||||
 | 
					                for (const option of this.#options) {
 | 
				
			||||||
 | 
					                    option.classList.remove("d-none");
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                this.#optionList.classList.remove("d-none");
 | 
				
			||||||
 | 
					                this.#queryNoResult.classList.add("d-none");
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            let hasAnyMatched = false;
 | 
				
			||||||
 | 
					            for (const option of this.#options) {
 | 
				
			||||||
 | 
					                const queryValues = JSON.parse(option.dataset.queryValues);
 | 
				
			||||||
 | 
					                let isMatched = false;
 | 
				
			||||||
 | 
					                for (const queryValue of queryValues) {
 | 
				
			||||||
 | 
					                    if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
 | 
				
			||||||
 | 
					                        isMatched = true;
 | 
				
			||||||
 | 
					                        break;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                if (isMatched) {
 | 
				
			||||||
 | 
					                    option.classList.remove("d-none");
 | 
				
			||||||
 | 
					                    hasAnyMatched = true;
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    option.classList.add("d-none");
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            if (isMatched) {
 | 
					            if (!hasAnyMatched) {
 | 
				
			||||||
                option.classList.remove("d-none");
 | 
					                this.#optionList.classList.add("d-none");
 | 
				
			||||||
                hasAnyMatched = true;
 | 
					                this.#queryNoResult.classList.remove("d-none");
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                option.classList.add("d-none");
 | 
					                this.#optionList.classList.remove("d-none");
 | 
				
			||||||
 | 
					                this.#queryNoResult.classList.add("d-none");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The callback when the base account selector is shown.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param baseCode {string} the active base code
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    onOpen(baseCode) {
 | 
				
			||||||
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
 | 
					            if (option.dataset.code === baseCode) {
 | 
				
			||||||
 | 
					                option.classList.add("active");
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                option.classList.remove("active");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (!hasAnyMatched) {
 | 
					        if (baseCode === "") {
 | 
				
			||||||
            optionList.classList.add("d-none");
 | 
					            this.#clearButton.classList.add("btn-secondary")
 | 
				
			||||||
            queryNoResult.classList.remove("d-none");
 | 
					            this.#clearButton.classList.remove("btn-danger");
 | 
				
			||||||
 | 
					            this.#clearButton.disabled = true;
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            optionList.classList.remove("d-none");
 | 
					            this.#clearButton.classList.add("btn-danger");
 | 
				
			||||||
            queryNoResult.classList.add("d-none");
 | 
					            this.#clearButton.classList.remove("btn-secondary")
 | 
				
			||||||
 | 
					            this.#clearButton.disabled = false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @returns {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateForm() {
 | 
					 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    isValid = validateBase() && isValid;
 | 
					 | 
				
			||||||
    isValid = validateTitle() && isValid;
 | 
					 | 
				
			||||||
    return isValid;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the base account.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @returns {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateBase() {
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-base-code");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-base-code-error");
 | 
					 | 
				
			||||||
    const displayField = document.getElementById("accounting-base");
 | 
					 | 
				
			||||||
    field.value = field.value.trim();
 | 
					 | 
				
			||||||
    if (field.value === "") {
 | 
					 | 
				
			||||||
        displayField.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please select the base account.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    displayField.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the title.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @returns {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateTitle() {
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-title");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-title-error");
 | 
					 | 
				
			||||||
    field.value = field.value.trim();
 | 
					 | 
				
			||||||
    if (field.value === "") {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please fill in the title.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
/* The Mia! Accounting Flask Project
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 * transaction-transfer-form.js: The JavaScript for the transfer transaction form
 | 
					 * account-selector.js: The JavaScript for the account selector
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*  Copyright (c) 2023 imacat.
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
@@ -22,11 +22,6 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
"use strict";
 | 
					"use strict";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Initializes the page JavaScript.
 | 
					 | 
				
			||||||
document.addEventListener("DOMContentLoaded", () => {
 | 
					 | 
				
			||||||
    AccountSelector.initialize();
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * The account selector.
 | 
					 * The account selector.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
@@ -34,10 +29,16 @@ document.addEventListener("DOMContentLoaded", () => {
 | 
				
			|||||||
class AccountSelector {
 | 
					class AccountSelector {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The entry type
 | 
					     * The line item editor
 | 
				
			||||||
 | 
					     * @type {JournalEntryLineItemEditor}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #lineItemEditor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Either "debit" or "credit"
 | 
				
			||||||
     * @type {string}
 | 
					     * @type {string}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #entryType;
 | 
					    #debitCredit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The prefix of the HTML ID and class
 | 
					     * The prefix of the HTML ID and class
 | 
				
			||||||
@@ -45,78 +46,81 @@ class AccountSelector {
 | 
				
			|||||||
     */
 | 
					     */
 | 
				
			||||||
    #prefix;
 | 
					    #prefix;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The button to clear the account
 | 
				
			||||||
 | 
					     * @type {HTMLButtonElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #clearButton
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The query input
 | 
				
			||||||
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #query;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message when the query has no result
 | 
				
			||||||
 | 
					     * @type {HTMLParagraphElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #queryNoResult;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The option list
 | 
				
			||||||
 | 
					     * @type {HTMLUListElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #optionList;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The options
 | 
				
			||||||
 | 
					     * @type {HTMLLIElement[]}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #options;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The more item to show all accounts
 | 
				
			||||||
 | 
					     * @type {HTMLLIElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #more;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs an account selector.
 | 
					     * Constructs an account selector.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param modal {HTMLFormElement} the account selector modal
 | 
					     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
				
			||||||
 | 
					     * @param debitCredit {string} either "debit" or "credit"
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(modal) {
 | 
					    constructor(lineItemEditor, debitCredit) {
 | 
				
			||||||
        this.#entryType = modal.dataset.entryType;
 | 
					        this.#lineItemEditor = lineItemEditor
 | 
				
			||||||
        this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
 | 
					        this.#debitCredit = debitCredit;
 | 
				
			||||||
        this.#init();
 | 
					        this.#prefix = "accounting-account-selector-" + debitCredit;
 | 
				
			||||||
    }
 | 
					        this.#query = document.getElementById(this.#prefix + "-query");
 | 
				
			||||||
 | 
					        this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
 | 
				
			||||||
    /**
 | 
					        this.#optionList = document.getElementById(this.#prefix + "-option-list");
 | 
				
			||||||
     * Initializes the account selector.
 | 
					        // noinspection JSValidateTypes
 | 
				
			||||||
     *
 | 
					        this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
				
			||||||
     */
 | 
					        this.#more = document.getElementById(this.#prefix + "-more");
 | 
				
			||||||
    #init() {
 | 
					        this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
 | 
				
			||||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
					        this.#more.onclick = () => {
 | 
				
			||||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
					            this.#more.classList.add("d-none");
 | 
				
			||||||
        const more = document.getElementById(this.#prefix + "-more");
 | 
					            this.#filterOptions();
 | 
				
			||||||
        const btnClear = document.getElementById(this.#prefix + "-btn-clear");
 | 
					 | 
				
			||||||
        const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
					 | 
				
			||||||
        more.onclick = () => {
 | 
					 | 
				
			||||||
            more.classList.add("d-none");
 | 
					 | 
				
			||||||
            this.#filterAccountOptions();
 | 
					 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
        this.#initializeAccountQuery();
 | 
					        this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
 | 
				
			||||||
        btnClear.onclick = () => {
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
            formAccountControl.classList.remove("accounting-not-empty");
 | 
					            option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
 | 
				
			||||||
            formAccount.innerText = "";
 | 
					 | 
				
			||||||
            formAccount.dataset.code = "";
 | 
					 | 
				
			||||||
            formAccount.dataset.text = "";
 | 
					 | 
				
			||||||
            validateJournalEntryAccount();
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
        for (const option of options) {
 | 
					 | 
				
			||||||
            option.onclick = () => {
 | 
					 | 
				
			||||||
                formAccountControl.classList.add("accounting-not-empty");
 | 
					 | 
				
			||||||
                formAccount.innerText = option.dataset.content;
 | 
					 | 
				
			||||||
                formAccount.dataset.code = option.dataset.code;
 | 
					 | 
				
			||||||
                formAccount.dataset.text = option.dataset.content;
 | 
					 | 
				
			||||||
                validateJournalEntryAccount();
 | 
					 | 
				
			||||||
            };
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					        this.#query.addEventListener("input", () => {
 | 
				
			||||||
 | 
					            this.#filterOptions();
 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Initializes the query on the account options.
 | 
					 | 
				
			||||||
     *
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    #initializeAccountQuery() {
 | 
					 | 
				
			||||||
        const query = document.getElementById(this.#prefix + "-query");
 | 
					 | 
				
			||||||
        query.addEventListener("input", () => {
 | 
					 | 
				
			||||||
            this.#filterAccountOptions();
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Filters the account options.
 | 
					     * Filters the options.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #filterAccountOptions() {
 | 
					    #filterOptions() {
 | 
				
			||||||
        const query = document.getElementById(this.#prefix + "-query");
 | 
					        const codesInUse = this.#getCodesUsedInForm();
 | 
				
			||||||
        const optionList = document.getElementById(this.#prefix + "-option-list");
 | 
					 | 
				
			||||||
        if (optionList === null) {
 | 
					 | 
				
			||||||
            console.log(this.#prefix + "-option-list");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
					 | 
				
			||||||
        const more = document.getElementById(this.#prefix + "-more");
 | 
					 | 
				
			||||||
        const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
 | 
					 | 
				
			||||||
        const codesInUse = this.#getAccountCodeUsedInForm();
 | 
					 | 
				
			||||||
        let shouldAnyShow = false;
 | 
					        let shouldAnyShow = false;
 | 
				
			||||||
        for (const option of options) {
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
            const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
 | 
					            const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
 | 
				
			||||||
            if (shouldShow) {
 | 
					            if (shouldShow) {
 | 
				
			||||||
                option.classList.remove("d-none");
 | 
					                option.classList.remove("d-none");
 | 
				
			||||||
                shouldAnyShow = true;
 | 
					                shouldAnyShow = true;
 | 
				
			||||||
@@ -124,12 +128,12 @@ class AccountSelector {
 | 
				
			|||||||
                option.classList.add("d-none");
 | 
					                option.classList.add("d-none");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (!shouldAnyShow && more.classList.contains("d-none")) {
 | 
					        if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
 | 
				
			||||||
            optionList.classList.add("d-none");
 | 
					            this.#optionList.classList.add("d-none");
 | 
				
			||||||
            queryNoResult.classList.remove("d-none");
 | 
					            this.#queryNoResult.classList.remove("d-none");
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            optionList.classList.remove("d-none");
 | 
					            this.#optionList.classList.remove("d-none");
 | 
				
			||||||
            queryNoResult.classList.add("d-none");
 | 
					            this.#queryNoResult.classList.add("d-none");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -138,33 +142,31 @@ class AccountSelector {
 | 
				
			|||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return {string[]} the account codes that are used in the form
 | 
					     * @return {string[]} the account codes that are used in the form
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #getAccountCodeUsedInForm() {
 | 
					    #getCodesUsedInForm() {
 | 
				
			||||||
        const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
 | 
					        const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
 | 
				
			||||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
					        if (this.#lineItemEditor.accountCode !== null) {
 | 
				
			||||||
        const inUse = [formAccount.dataset.code];
 | 
					            inUse.push(this.#lineItemEditor.accountCode);
 | 
				
			||||||
        for (const accountCode of accountCodes) {
 | 
					 | 
				
			||||||
            inUse.push(accountCode.value);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return inUse
 | 
					        return inUse
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Returns whether an account option should show.
 | 
					     * Returns whether an option should show.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param option {HTMLLIElement} the account option
 | 
					     * @param option {HTMLLIElement} the option
 | 
				
			||||||
     * @param more {HTMLLIElement} the more account element
 | 
					     * @param more {HTMLLIElement} the more element
 | 
				
			||||||
     * @param inUse {string[]} the account codes that are used in the form
 | 
					     * @param inUse {string[]} the account codes that are used in the form
 | 
				
			||||||
     * @param query {HTMLInputElement} the query element, if any
 | 
					     * @param query {HTMLInputElement} the query element, if any
 | 
				
			||||||
     * @return {boolean} true if the account option should show, or false otherwise
 | 
					     * @return {boolean} true if the option should show, or false otherwise
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #shouldAccountOptionShow(option, more, inUse, query) {
 | 
					    #shouldOptionShow(option, more, inUse, query) {
 | 
				
			||||||
        const isQueryMatched = () => {
 | 
					        const isQueryMatched = () => {
 | 
				
			||||||
            if (query.value === "") {
 | 
					            if (query.value === "") {
 | 
				
			||||||
                return true;
 | 
					                return true;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
					            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
				
			||||||
            for (const queryValue of queryValues) {
 | 
					            for (const queryValue of queryValues) {
 | 
				
			||||||
                if (queryValue.includes(query.value)) {
 | 
					                if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
 | 
				
			||||||
                    return true;
 | 
					                    return true;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
@@ -180,71 +182,43 @@ class AccountSelector {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Initializes the account selector when it is shown.
 | 
					     * The callback when the account selector is shown.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    initShow() {
 | 
					    onOpen() {
 | 
				
			||||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
					        this.#query.value = "";
 | 
				
			||||||
        const query = document.getElementById(this.#prefix + "-query")
 | 
					        this.#more.classList.remove("d-none");
 | 
				
			||||||
        const more = document.getElementById(this.#prefix + "-more");
 | 
					        this.#filterOptions();
 | 
				
			||||||
        const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
        const btnClear = document.getElementById(this.#prefix + "-btn-clear");
 | 
					            if (option.dataset.code === this.#lineItemEditor.accountCode) {
 | 
				
			||||||
        query.value = "";
 | 
					 | 
				
			||||||
        more.classList.remove("d-none");
 | 
					 | 
				
			||||||
        this.#filterAccountOptions();
 | 
					 | 
				
			||||||
        for (const option of options) {
 | 
					 | 
				
			||||||
            if (option.dataset.code === formAccount.dataset.code) {
 | 
					 | 
				
			||||||
                option.classList.add("active");
 | 
					                option.classList.add("active");
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                option.classList.remove("active");
 | 
					                option.classList.remove("active");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (formAccount.dataset.code === "") {
 | 
					        if (this.#lineItemEditor.accountCode === null) {
 | 
				
			||||||
            btnClear.classList.add("btn-secondary");
 | 
					            this.#clearButton.classList.add("btn-secondary");
 | 
				
			||||||
            btnClear.classList.remove("btn-danger");
 | 
					            this.#clearButton.classList.remove("btn-danger");
 | 
				
			||||||
            btnClear.disabled = true;
 | 
					            this.#clearButton.disabled = true;
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            btnClear.classList.add("btn-danger");
 | 
					            this.#clearButton.classList.add("btn-danger");
 | 
				
			||||||
            btnClear.classList.remove("btn-secondary");
 | 
					            this.#clearButton.classList.remove("btn-secondary");
 | 
				
			||||||
            btnClear.disabled = false;
 | 
					            this.#clearButton.disabled = false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The account selectors.
 | 
					     * Returns the account selector instances.
 | 
				
			||||||
     * @type {{debit: AccountSelector, credit: AccountSelector}}
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    static #selectors = {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Initializes the account selectors.
 | 
					 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
 | 
					     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
				
			||||||
 | 
					     * @return {{debit: AccountSelector, credit: AccountSelector}}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    static initialize() {
 | 
					    static getInstances(lineItemEditor) {
 | 
				
			||||||
        const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
 | 
					        const selectors = {}
 | 
				
			||||||
 | 
					        const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
 | 
				
			||||||
        for (const modal of modals) {
 | 
					        for (const modal of modals) {
 | 
				
			||||||
            const selector = new AccountSelector(modal);
 | 
					            selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
 | 
				
			||||||
            this.#selectors[selector.#entryType] = selector;
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.#initializeTransactionForm();
 | 
					        return selectors;
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Initializes the transaction form.
 | 
					 | 
				
			||||||
     *
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    static #initializeTransactionForm() {
 | 
					 | 
				
			||||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
					 | 
				
			||||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
					 | 
				
			||||||
        formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Initializes the account selector for the journal entry form.
 | 
					 | 
				
			||||||
     *x
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    static initializeJournalEntryForm() {
 | 
					 | 
				
			||||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
					 | 
				
			||||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
					 | 
				
			||||||
        formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,152 +24,151 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Initializes the page JavaScript.
 | 
					// Initializes the page JavaScript.
 | 
				
			||||||
document.addEventListener("DOMContentLoaded", () => {
 | 
					document.addEventListener("DOMContentLoaded", () => {
 | 
				
			||||||
    document.getElementById("accounting-code")
 | 
					    CurrencyForm.initialize();
 | 
				
			||||||
        .onchange = validateCode;
 | 
					 | 
				
			||||||
    document.getElementById("accounting-name")
 | 
					 | 
				
			||||||
        .onchange = validateName;
 | 
					 | 
				
			||||||
    document.getElementById("accounting-form")
 | 
					 | 
				
			||||||
        .onsubmit = validateForm;
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * The asynchronous validation result
 | 
					 * The currency form.
 | 
				
			||||||
 * @type {object}
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
let isAsyncValid = {};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @returns {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateForm() {
 | 
					 | 
				
			||||||
    isAsyncValid = {
 | 
					 | 
				
			||||||
        "code": false,
 | 
					 | 
				
			||||||
        "_sync": false,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    isValid = validateCode() && isValid;
 | 
					 | 
				
			||||||
    isValid = validateName() && isValid;
 | 
					 | 
				
			||||||
    isAsyncValid["_sync"] = isValid;
 | 
					 | 
				
			||||||
    submitFormIfAllAsyncValid();
 | 
					 | 
				
			||||||
    return false;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Submits the form if the whole form passed the asynchronous
 | 
					 | 
				
			||||||
 * validations.
 | 
					 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * @private
 | 
					 * @private
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
function submitFormIfAllAsyncValid() {
 | 
					class CurrencyForm {
 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    for (const key of Object.keys(isAsyncValid)) {
 | 
					 | 
				
			||||||
        isValid = isAsyncValid[key] && isValid;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (isValid) {
 | 
					 | 
				
			||||||
        document.getElementById("accounting-form").submit()
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					    /**
 | 
				
			||||||
 * Validates the code.
 | 
					     * The form.
 | 
				
			||||||
 *
 | 
					     * @type {HTMLFormElement}
 | 
				
			||||||
 * @param changeEvent {Event} the change event, if invoked from onchange
 | 
					     */
 | 
				
			||||||
 * @returns {boolean} true if valid, or false otherwise
 | 
					    #formElement;
 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateCode(changeEvent = null) {
 | 
					 | 
				
			||||||
    const key = "code";
 | 
					 | 
				
			||||||
    const isSubmission = changeEvent === null;
 | 
					 | 
				
			||||||
    let hasAsyncValidation = false;
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-code");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-code-error");
 | 
					 | 
				
			||||||
    field.value = field.value.trim();
 | 
					 | 
				
			||||||
    if (field.value === "") {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please fill in the code.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    const blocklist = JSON.parse(field.dataset.blocklist);
 | 
					 | 
				
			||||||
    if (blocklist.includes(field.value)) {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("This code is not available.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (!field.value.match(/^[A-Z]{3}$/)) {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    const original = field.dataset.original;
 | 
					 | 
				
			||||||
    if (original === "" || field.value !== original) {
 | 
					 | 
				
			||||||
        hasAsyncValidation = true;
 | 
					 | 
				
			||||||
        validateAsyncCodeIsDuplicated(isSubmission, key);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (!hasAsyncValidation) {
 | 
					 | 
				
			||||||
        isAsyncValid[key] = true;
 | 
					 | 
				
			||||||
        field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = "";
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					    /**
 | 
				
			||||||
 * Validates asynchronously whether the code is duplicated.
 | 
					     * The code
 | 
				
			||||||
 * The boolean validation result is stored in isAsyncValid[key].
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
 *
 | 
					     */
 | 
				
			||||||
 * @param isSubmission {boolean} whether this is invoked from a form submission
 | 
					    #code;
 | 
				
			||||||
 * @param key {string} the key to store the result in isAsyncValid
 | 
					
 | 
				
			||||||
 * @private
 | 
					    /**
 | 
				
			||||||
 */
 | 
					     * The error message of the code
 | 
				
			||||||
function validateAsyncCodeIsDuplicated(isSubmission, key) {
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
    const field = document.getElementById("accounting-code");
 | 
					     */
 | 
				
			||||||
    const error = document.getElementById("accounting-code-error");
 | 
					    #codeError;
 | 
				
			||||||
    const url = field.dataset.existsUrl;
 | 
					
 | 
				
			||||||
    const onLoad = function () {
 | 
					    /**
 | 
				
			||||||
        if (this.status === 200) {
 | 
					     * The name
 | 
				
			||||||
            const result = JSON.parse(this.responseText);
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
            if (result["exists"]) {
 | 
					     */
 | 
				
			||||||
                field.classList.add("is-invalid");
 | 
					    #name;
 | 
				
			||||||
                error.innerText = A_("Code conflicts with another currency.");
 | 
					
 | 
				
			||||||
                if (isSubmission) {
 | 
					    /**
 | 
				
			||||||
                    isAsyncValid[key] = false;
 | 
					     * The error message of the name
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #nameError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Constructs the currency form.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        this.#formElement = document.getElementById("accounting-form");
 | 
				
			||||||
 | 
					        this.#code = document.getElementById("accounting-code");
 | 
				
			||||||
 | 
					        this.#codeError = document.getElementById("accounting-code-error");
 | 
				
			||||||
 | 
					        this.#name = document.getElementById("accounting-name");
 | 
				
			||||||
 | 
					        this.#nameError = document.getElementById("accounting-name-error");
 | 
				
			||||||
 | 
					        this.#code.onchange = () => {
 | 
				
			||||||
 | 
					            this.#validateCode().then();
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        this.#name.onchange = () => {
 | 
				
			||||||
 | 
					            this.#validateName();
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        this.#formElement.onsubmit = () => {
 | 
				
			||||||
 | 
					            this.#validateForm().then((isValid) => {
 | 
				
			||||||
 | 
					                if (isValid) {
 | 
				
			||||||
 | 
					                    this.#formElement.submit();
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                return;
 | 
					            });
 | 
				
			||||||
            }
 | 
					            return false;
 | 
				
			||||||
            field.classList.remove("is-invalid");
 | 
					        };
 | 
				
			||||||
            error.innerText = "";
 | 
					    }
 | 
				
			||||||
            if (isSubmission) {
 | 
					
 | 
				
			||||||
                isAsyncValid[key] = true;
 | 
					    /**
 | 
				
			||||||
                submitFormIfAllAsyncValid();
 | 
					     * Validates the form.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @returns {Promise<boolean>} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async #validateForm() {
 | 
				
			||||||
 | 
					        let isValid = true;
 | 
				
			||||||
 | 
					        isValid = await this.#validateCode() && isValid;
 | 
				
			||||||
 | 
					        isValid = this.#validateName() && isValid;
 | 
				
			||||||
 | 
					        return isValid;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the code.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param changeEvent {Event} the change event, if invoked from onchange
 | 
				
			||||||
 | 
					     * @returns {Promise<boolean>} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async #validateCode(changeEvent = null) {
 | 
				
			||||||
 | 
					        this.#code.value = this.#code.value.trim();
 | 
				
			||||||
 | 
					        if (this.#code.value === "") {
 | 
				
			||||||
 | 
					            this.#code.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#codeError.innerText = A_("Please fill in the code.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        const blocklist = JSON.parse(this.#code.dataset.blocklist);
 | 
				
			||||||
 | 
					        if (blocklist.includes(this.#code.value)) {
 | 
				
			||||||
 | 
					            this.#code.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#codeError.innerText = A_("This code is not available.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (!this.#code.value.match(/^[A-Z]{3}$/)) {
 | 
				
			||||||
 | 
					            this.#code.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        const original = this.#code.dataset.original;
 | 
				
			||||||
 | 
					        if (original === "" || this.#code.value !== original) {
 | 
				
			||||||
 | 
					            const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
 | 
				
			||||||
 | 
					            const data = await response.json();
 | 
				
			||||||
 | 
					            if (data["exists"]) {
 | 
				
			||||||
 | 
					                this.#code.classList.add("is-invalid");
 | 
				
			||||||
 | 
					                this.#codeError.innerText = A_("Code conflicts with another currency.");
 | 
				
			||||||
 | 
					                return false;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    };
 | 
					        this.#code.classList.remove("is-invalid");
 | 
				
			||||||
    const request = new XMLHttpRequest();
 | 
					        this.#codeError.innerText = "";
 | 
				
			||||||
    request.onload = onLoad;
 | 
					        return true;
 | 
				
			||||||
    request.open("GET", url + "?q=" + encodeURIComponent(field.value));
 | 
					    }
 | 
				
			||||||
    request.send();
 | 
					
 | 
				
			||||||
}
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the name.
 | 
				
			||||||
/**
 | 
					     *
 | 
				
			||||||
 * Validates the name.
 | 
					     * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 *
 | 
					     */
 | 
				
			||||||
 * @returns {boolean} true if valid, or false otherwise
 | 
					    #validateName() {
 | 
				
			||||||
 * @private
 | 
					        this.#name.value = this.#name.value.trim();
 | 
				
			||||||
 */
 | 
					        if (this.#name.value === "") {
 | 
				
			||||||
function validateName() {
 | 
					            this.#name.classList.add("is-invalid");
 | 
				
			||||||
    const field = document.getElementById("accounting-name");
 | 
					            this.#nameError.innerText = A_("Please fill in the name.");
 | 
				
			||||||
    const error = document.getElementById("accounting-name-error");
 | 
					            return false;
 | 
				
			||||||
    field.value = field.value.trim();
 | 
					        }
 | 
				
			||||||
    if (field.value === "") {
 | 
					        this.#name.classList.remove("is-invalid");
 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					        this.#nameError.innerText = "";
 | 
				
			||||||
        error.innerText = A_("Please fill in the name.");
 | 
					        return true;
 | 
				
			||||||
        return false;
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The form
 | 
				
			||||||
 | 
					     * @type {CurrencyForm}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static #form;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Initializes the currency form.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    static initialize() {
 | 
				
			||||||
 | 
					        this.#form = new CurrencyForm();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
/* The Mia! Accounting Flask Project
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 * summary-editor.js: The JavaScript for the summary editor
 | 
					 * description-editor.js: The JavaScript for the description editor
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*  Copyright (c) 2023 imacat.
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
@@ -22,19 +22,20 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
"use strict";
 | 
					"use strict";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Initializes the page JavaScript.
 | 
					 | 
				
			||||||
document.addEventListener("DOMContentLoaded", () => {
 | 
					 | 
				
			||||||
    SummaryEditor.initialize();
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * A summary editor.
 | 
					 * A description editor.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
class SummaryEditor {
 | 
					class DescriptionEditor {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The summary editor form
 | 
					     * The line item editor
 | 
				
			||||||
 | 
					     * @type {JournalEntryLineItemEditor}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    lineItemEditor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The description editor form
 | 
				
			||||||
     * @type {HTMLFormElement}
 | 
					     * @type {HTMLFormElement}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #form;
 | 
					    #form;
 | 
				
			||||||
@@ -46,37 +47,43 @@ class SummaryEditor {
 | 
				
			|||||||
    prefix;
 | 
					    prefix;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The modal of the summary editor
 | 
					     * The modal of the description editor
 | 
				
			||||||
     * @type {HTMLDivElement}
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #modal;
 | 
					    #modal;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The entry type, either "debit" or "credit"
 | 
					     * Either "debit" or "credit"
 | 
				
			||||||
     * @type {string}
 | 
					     * @type {string}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #entryType;
 | 
					    debitCredit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The current tab.
 | 
					     * The current tab
 | 
				
			||||||
     * @type {TabPlane}
 | 
					     * @type {TabPlane}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    currentTab;
 | 
					    currentTab;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The summary input.
 | 
					     * The description input
 | 
				
			||||||
     * @type {HTMLInputElement}
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    summary;
 | 
					    description;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The number input.
 | 
					     * The button to the original line item selector
 | 
				
			||||||
 | 
					     * @type {HTMLButtonElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #offsetButton;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The number input
 | 
				
			||||||
     * @type {HTMLInputElement}
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    number;
 | 
					    number;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The note.
 | 
					     * The note
 | 
				
			||||||
     * @type {HTMLInputElement}
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    note;
 | 
					    note;
 | 
				
			||||||
@@ -93,72 +100,39 @@ class SummaryEditor {
 | 
				
			|||||||
     */
 | 
					     */
 | 
				
			||||||
    #selectedAccount = null;
 | 
					    #selectedAccount = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * The modal of the journal entry form
 | 
					 | 
				
			||||||
     * @type {HTMLDivElement}
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    #entryFormModal;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * The control of the account on the journal entry form
 | 
					 | 
				
			||||||
     * @type {HTMLDivElement}
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    #formAccountControl;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * The account on the journal entry form
 | 
					 | 
				
			||||||
     * @type {HTMLDivElement}
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    #formAccount;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * The control of the summary on the journal entry form
 | 
					 | 
				
			||||||
     * @type {HTMLDivElement}
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    #formSummaryControl;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * The summary on the journal entry form
 | 
					 | 
				
			||||||
     * @type {HTMLDivElement}
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    #formSummary;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The tab planes
 | 
					     * The tab planes
 | 
				
			||||||
     * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
 | 
					     * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    tabPlanes = {};
 | 
					    tabPlanes = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs a summary editor.
 | 
					     * Constructs a description editor.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param form {HTMLFormElement} the summary editor form
 | 
					     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
				
			||||||
 | 
					     * @param debitCredit {string} either "debit" or "credit"
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(form) {
 | 
					    constructor(lineItemEditor, debitCredit) {
 | 
				
			||||||
        this.#form = form;
 | 
					        this.lineItemEditor = lineItemEditor;
 | 
				
			||||||
        this.#entryType = form.dataset.entryType;
 | 
					        this.debitCredit = debitCredit;
 | 
				
			||||||
        this.prefix = "accounting-summary-editor-" + form.dataset.entryType;
 | 
					        this.prefix = "accounting-description-editor-" + debitCredit;
 | 
				
			||||||
 | 
					        this.#form = document.getElementById(this.prefix);
 | 
				
			||||||
        this.#modal = document.getElementById(this.prefix + "-modal");
 | 
					        this.#modal = document.getElementById(this.prefix + "-modal");
 | 
				
			||||||
        this.summary = document.getElementById(this.prefix + "-summary");
 | 
					        this.description = document.getElementById(this.prefix + "-description");
 | 
				
			||||||
 | 
					        this.#offsetButton = document.getElementById(this.prefix + "-offset");
 | 
				
			||||||
        this.number = document.getElementById(this.prefix + "-annotation-number");
 | 
					        this.number = document.getElementById(this.prefix + "-annotation-number");
 | 
				
			||||||
        this.note = document.getElementById(this.prefix + "-annotation-note");
 | 
					        this.note = document.getElementById(this.prefix + "-annotation-note");
 | 
				
			||||||
        // noinspection JSValidateTypes
 | 
					        // noinspection JSValidateTypes
 | 
				
			||||||
        this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
 | 
					        this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Things from the entry form
 | 
					        for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) {
 | 
				
			||||||
        this.#entryFormModal = document.getElementById("accounting-entry-form-modal");
 | 
					 | 
				
			||||||
        this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
					 | 
				
			||||||
        this.#formAccount = document.getElementById("accounting-entry-form-account");
 | 
					 | 
				
			||||||
        this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
					 | 
				
			||||||
        this.#formSummary = document.getElementById("accounting-entry-form-summary");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
 | 
					 | 
				
			||||||
            const tab = new cls(this);
 | 
					            const tab = new cls(this);
 | 
				
			||||||
            this.tabPlanes[tab.tabId()] = tab;
 | 
					            this.tabPlanes[tab.tabId()] = tab;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.currentTab = this.tabPlanes.general;
 | 
					        this.currentTab = this.tabPlanes.general;
 | 
				
			||||||
        this.#initializeSuggestedAccounts();
 | 
					        this.#initializeSuggestedAccounts();
 | 
				
			||||||
        this.summary.onchange = () => this.#onSummaryChange();
 | 
					        this.description.onchange = () => this.#onDescriptionChange();
 | 
				
			||||||
 | 
					        this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
 | 
				
			||||||
        this.#form.onsubmit = () => {
 | 
					        this.#form.onsubmit = () => {
 | 
				
			||||||
            if (this.currentTab.validate()) {
 | 
					            if (this.currentTab.validate()) {
 | 
				
			||||||
                this.#submit();
 | 
					                this.#submit();
 | 
				
			||||||
@@ -168,12 +142,12 @@ class SummaryEditor {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The callback when the summary input is changed.
 | 
					     * The callback when the description input is changed.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #onSummaryChange() {
 | 
					    #onDescriptionChange() {
 | 
				
			||||||
        this.summary.value = this.summary.value.trim();
 | 
					        this.description.value = this.description.value.trim();
 | 
				
			||||||
        for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
 | 
					        for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
 | 
				
			||||||
            if (tabPlane.populate()) {
 | 
					            if (tabPlane.populate()) {
 | 
				
			||||||
                break;
 | 
					                break;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
@@ -184,27 +158,31 @@ class SummaryEditor {
 | 
				
			|||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Filters the suggested accounts.
 | 
					     * Filters the suggested accounts.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param tagButton {HTMLButtonElement|null} the tag button
 | 
					     * @param tagButton {HTMLButtonElement} the tag button
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    filterSuggestedAccounts(tagButton) {
 | 
					    filterSuggestedAccounts(tagButton) {
 | 
				
			||||||
        for (const accountButton of this.#accountButtons) {
 | 
					        this.clearSuggestedAccounts();
 | 
				
			||||||
            accountButton.classList.add("d-none");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        if (tagButton === null) {
 | 
					 | 
				
			||||||
            this.#selectAccount(null);
 | 
					 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        const suggested = JSON.parse(tagButton.dataset.accounts);
 | 
					        const suggested = JSON.parse(tagButton.dataset.accounts);
 | 
				
			||||||
        let selectedAccountButton = null;
 | 
					 | 
				
			||||||
        for (const accountButton of this.#accountButtons) {
 | 
					        for (const accountButton of this.#accountButtons) {
 | 
				
			||||||
            if (suggested.includes(accountButton.dataset.code)) {
 | 
					            if (suggested.includes(accountButton.dataset.code)) {
 | 
				
			||||||
                accountButton.classList.remove("d-none");
 | 
					                accountButton.classList.remove("d-none");
 | 
				
			||||||
                if (accountButton.dataset.code === suggested[0]) {
 | 
					                if (accountButton.dataset.code === suggested[0]) {
 | 
				
			||||||
                    selectedAccountButton = accountButton;
 | 
					                    this.#selectAccount(accountButton);
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.#selectAccount(selectedAccountButton);
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Clears the suggested accounts.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    clearSuggestedAccounts() {
 | 
				
			||||||
 | 
					        for (const accountButton of this.#accountButtons) {
 | 
				
			||||||
 | 
					            accountButton.classList.add("d-none");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#selectAccount(null);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -235,43 +213,34 @@ class SummaryEditor {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Submits the summary.
 | 
					     * Submits the description.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #submit() {
 | 
					    #submit() {
 | 
				
			||||||
        if (this.summary.value === "") {
 | 
					 | 
				
			||||||
            this.#formSummaryControl.classList.remove("accounting-not-empty");
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            this.#formSummaryControl.classList.add("accounting-not-empty");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        if (this.#selectedAccount !== null) {
 | 
					 | 
				
			||||||
            this.#formAccountControl.classList.add("accounting-not-empty");
 | 
					 | 
				
			||||||
            this.#formAccount.dataset.code = this.#selectedAccount.dataset.code;
 | 
					 | 
				
			||||||
            this.#formAccount.dataset.text = this.#selectedAccount.dataset.text;
 | 
					 | 
				
			||||||
            this.#formAccount.innerText = this.#selectedAccount.dataset.text;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        this.#formSummary.dataset.value = this.summary.value;
 | 
					 | 
				
			||||||
        this.#formSummary.innerText = this.summary.value;
 | 
					 | 
				
			||||||
        bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
 | 
					        bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
 | 
				
			||||||
        bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show();
 | 
					        if (this.#selectedAccount !== null) {
 | 
				
			||||||
 | 
					            this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.lineItemEditor.saveDescription(this.description.value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The callback when the summary editor is shown.
 | 
					     * The callback when the description editor is shown.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #onOpen() {
 | 
					    onOpen() {
 | 
				
			||||||
        this.#reset();
 | 
					        this.#reset();
 | 
				
			||||||
        this.summary.value = this.#formSummary.dataset.value;
 | 
					        this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
 | 
				
			||||||
        this.#onSummaryChange();
 | 
					        this.#onDescriptionChange();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Resets the summary editor.
 | 
					     * Resets the description editor.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #reset() {
 | 
					    #reset() {
 | 
				
			||||||
        this.summary.value = "";
 | 
					        this.description.value = "";
 | 
				
			||||||
        for (const tabPlane of Object.values(this.tabPlanes)) {
 | 
					        for (const tabPlane of Object.values(this.tabPlanes)) {
 | 
				
			||||||
            tabPlane.reset();
 | 
					            tabPlane.reset();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -279,33 +248,18 @@ class SummaryEditor {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The summary editors.
 | 
					     * Returns the description editor instances.
 | 
				
			||||||
     * @type {{debit: SummaryEditor, credit: SummaryEditor}}
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    static #editors = {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Initializes the summary editors.
 | 
					 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
 | 
					     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
				
			||||||
 | 
					     * @return {{debit: DescriptionEditor, credit: DescriptionEditor}}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    static initialize() {
 | 
					    static getInstances(lineItemEditor) {
 | 
				
			||||||
        const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
 | 
					        const editors = {}
 | 
				
			||||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
					        const forms = Array.from(document.getElementsByClassName("accounting-description-editor"));
 | 
				
			||||||
        const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
					 | 
				
			||||||
        for (const form of forms) {
 | 
					        for (const form of forms) {
 | 
				
			||||||
            const editor = new SummaryEditor(form);
 | 
					            editors[form.dataset.debitCredit] = new DescriptionEditor(lineItemEditor, form.dataset.debitCredit);
 | 
				
			||||||
            this.#editors[editor.#entryType] = editor;
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        formSummaryControl.onclick = () => this.#editors[entryForm.dataset.entryType].#onOpen()
 | 
					        return editors;
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Initializes the summary editor for a new journal entry.
 | 
					 | 
				
			||||||
     *
 | 
					 | 
				
			||||||
     * @param entryType {string} the entry type, either "debit" or "credit"
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    static initializeNewJournalEntry(entryType) {
 | 
					 | 
				
			||||||
        this.#editors[entryType].#onOpen();
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -318,8 +272,8 @@ class SummaryEditor {
 | 
				
			|||||||
class TabPlane {
 | 
					class TabPlane {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The parent summary editor
 | 
					     * The parent description editor
 | 
				
			||||||
     * @type {SummaryEditor}
 | 
					     * @type {DescriptionEditor}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    editor;
 | 
					    editor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -344,7 +298,7 @@ class TabPlane {
 | 
				
			|||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs a tab plane.
 | 
					     * Constructs a tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param editor {SummaryEditor} the parent summary editor
 | 
					     * @param editor {DescriptionEditor} the parent description editor
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(editor) {
 | 
					    constructor(editor) {
 | 
				
			||||||
        this.editor = editor;
 | 
					        this.editor = editor;
 | 
				
			||||||
@@ -370,9 +324,9 @@ class TabPlane {
 | 
				
			|||||||
    reset() { throw new Error("Method not implemented."); }
 | 
					    reset() { throw new Error("Method not implemented."); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Populates the tab plane with the summary input.
 | 
					     * Populates the tab plane with the description input.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return {boolean} true if the summary input matches this tab, or false otherwise
 | 
					     * @return {boolean} true if the description input matches this tab, or false otherwise
 | 
				
			||||||
     * @abstract
 | 
					     * @abstract
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    populate() { throw new Error("Method not implemented."); }
 | 
					    populate() { throw new Error("Method not implemented."); }
 | 
				
			||||||
@@ -433,7 +387,7 @@ class TagTabPlane extends TabPlane {
 | 
				
			|||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs a tab plane.
 | 
					     * Constructs a tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param editor {SummaryEditor} the parent summary editor
 | 
					     * @param editor {DescriptionEditor} the parent description editor
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(editor) {
 | 
					    constructor(editor) {
 | 
				
			||||||
@@ -445,7 +399,7 @@ class TagTabPlane extends TabPlane {
 | 
				
			|||||||
        this.initializeTagButtons();
 | 
					        this.initializeTagButtons();
 | 
				
			||||||
        this.tag.onchange = () => {
 | 
					        this.tag.onchange = () => {
 | 
				
			||||||
            this.onTagChange();
 | 
					            this.onTagChange();
 | 
				
			||||||
            this.updateSummary();
 | 
					            this.updateDescription();
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -468,17 +422,17 @@ class TagTabPlane extends TabPlane {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (!isMatched) {
 | 
					        if (!isMatched) {
 | 
				
			||||||
            this.editor.filterSuggestedAccounts(null);
 | 
					            this.editor.clearSuggestedAccounts();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.validateTag();
 | 
					        this.validateTag();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Updates the summary according to the input in the tab plane.
 | 
					     * Updates the description according to the input in the tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @abstract
 | 
					     * @abstract
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    updateSummary() { throw new Error("Method not implemented."); }
 | 
					    updateDescription() { throw new Error("Method not implemented."); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Switches to the tab plane.
 | 
					     * Switches to the tab plane.
 | 
				
			||||||
@@ -486,14 +440,13 @@ class TagTabPlane extends TabPlane {
 | 
				
			|||||||
     */
 | 
					     */
 | 
				
			||||||
    switchToMe() {
 | 
					    switchToMe() {
 | 
				
			||||||
        super.switchToMe();
 | 
					        super.switchToMe();
 | 
				
			||||||
        let selectedTagButton = null;
 | 
					 | 
				
			||||||
        for (const tagButton of this.tagButtons) {
 | 
					        for (const tagButton of this.tagButtons) {
 | 
				
			||||||
            if (tagButton.classList.contains("btn-primary")) {
 | 
					            if (tagButton.classList.contains("btn-primary")) {
 | 
				
			||||||
                selectedTagButton = tagButton;
 | 
					                this.editor.filterSuggestedAccounts(tagButton);
 | 
				
			||||||
                break;
 | 
					                return;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.editor.filterSuggestedAccounts(selectedTagButton);
 | 
					        this.editor.clearSuggestedAccounts();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -511,7 +464,7 @@ class TagTabPlane extends TabPlane {
 | 
				
			|||||||
                tagButton.classList.add("btn-primary");
 | 
					                tagButton.classList.add("btn-primary");
 | 
				
			||||||
                this.tag.value = tagButton.dataset.value;
 | 
					                this.tag.value = tagButton.dataset.value;
 | 
				
			||||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
					                this.editor.filterSuggestedAccounts(tagButton);
 | 
				
			||||||
                this.updateSummary();
 | 
					                this.updateDescription();
 | 
				
			||||||
            };
 | 
					            };
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -547,7 +500,7 @@ class TagTabPlane extends TabPlane {
 | 
				
			|||||||
        errorContainer.innerText = "";
 | 
					        errorContainer.innerText = "";
 | 
				
			||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Resets the tab plane input.
 | 
					     * Resets the tab plane input.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
@@ -582,28 +535,28 @@ class GeneralTagTab extends TagTabPlane {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Updates the summary according to the input in the tab plane.
 | 
					     * Updates the description according to the input in the tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    updateSummary() {
 | 
					    updateDescription() {
 | 
				
			||||||
        const pos = this.editor.summary.value.indexOf("—");
 | 
					        const pos = this.editor.description.value.indexOf("—");
 | 
				
			||||||
        const prefix = this.tag.value === ""? "": this.tag.value + "—";
 | 
					        const prefix = this.tag.value === ""? "": this.tag.value + "—";
 | 
				
			||||||
        if (pos === -1) {
 | 
					        if (pos === -1) {
 | 
				
			||||||
            this.editor.summary.value = prefix + this.editor.summary.value;
 | 
					            this.editor.description.value = prefix + this.editor.description.value;
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            this.editor.summary.value = prefix + this.editor.summary.value.substring(pos + 1);
 | 
					            this.editor.description.value = prefix + this.editor.description.value.substring(pos + 1);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Populates the tab plane with the summary input.
 | 
					     * Populates the tab plane with the description input.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return {boolean} true if the summary input matches this tab, or false otherwise
 | 
					     * @return {boolean} true if the description input matches this tab, or false otherwise
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    populate() {
 | 
					    populate() {
 | 
				
			||||||
        const found = this.editor.summary.value.match(/^([^—]+)—/);
 | 
					        const found = this.editor.description.value.match(/^([^—]+)—/);
 | 
				
			||||||
        if (found === null) {
 | 
					        if (found === null) {
 | 
				
			||||||
            return false;
 | 
					            return false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -611,13 +564,6 @@ class GeneralTagTab extends TagTabPlane {
 | 
				
			|||||||
            this.tag.value = found[1];
 | 
					            this.tag.value = found[1];
 | 
				
			||||||
            this.onTagChange();
 | 
					            this.onTagChange();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        for (const tagButton of this.tagButtons) {
 | 
					 | 
				
			||||||
            if (tagButton.dataset.value === this.tag.value) {
 | 
					 | 
				
			||||||
                tagButton.classList.remove("btn-outline-primary");
 | 
					 | 
				
			||||||
                tagButton.classList.add("btn-primary");
 | 
					 | 
				
			||||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        this.switchToMe();
 | 
					        this.switchToMe();
 | 
				
			||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -672,7 +618,7 @@ class GeneralTripTab extends TagTabPlane {
 | 
				
			|||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs a tab plane.
 | 
					     * Constructs a tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param editor {SummaryEditor} the parent summary editor
 | 
					     * @param editor {DescriptionEditor} the parent description editor
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(editor) {
 | 
					    constructor(editor) {
 | 
				
			||||||
@@ -685,7 +631,7 @@ class GeneralTripTab extends TagTabPlane {
 | 
				
			|||||||
        this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction"));
 | 
					        this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction"));
 | 
				
			||||||
        this.#from.onchange = () => {
 | 
					        this.#from.onchange = () => {
 | 
				
			||||||
            this.#from.value = this.#from.value.trim();
 | 
					            this.#from.value = this.#from.value.trim();
 | 
				
			||||||
            this.updateSummary();
 | 
					            this.updateDescription();
 | 
				
			||||||
            this.validateFrom();
 | 
					            this.validateFrom();
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
        for (const directionButton of this.#directionButtons) {
 | 
					        for (const directionButton of this.#directionButtons) {
 | 
				
			||||||
@@ -696,12 +642,12 @@ class GeneralTripTab extends TagTabPlane {
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
                directionButton.classList.remove("btn-outline-primary");
 | 
					                directionButton.classList.remove("btn-outline-primary");
 | 
				
			||||||
                directionButton.classList.add("btn-primary");
 | 
					                directionButton.classList.add("btn-primary");
 | 
				
			||||||
                this.updateSummary();
 | 
					                this.updateDescription();
 | 
				
			||||||
            };
 | 
					            };
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.#to.onchange = () => {
 | 
					        this.#to.onchange = () => {
 | 
				
			||||||
            this.#to.value = this.#to.value.trim();
 | 
					            this.#to.value = this.#to.value.trim();
 | 
				
			||||||
            this.updateSummary();
 | 
					            this.updateDescription();
 | 
				
			||||||
            this.validateTo();
 | 
					            this.validateTo();
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -717,11 +663,11 @@ class GeneralTripTab extends TagTabPlane {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Updates the summary according to the input in the tab plane.
 | 
					     * Updates the description according to the input in the tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    updateSummary() {
 | 
					    updateDescription() {
 | 
				
			||||||
        let direction;
 | 
					        let direction;
 | 
				
			||||||
        for (const directionButton of this.#directionButtons) {
 | 
					        for (const directionButton of this.#directionButtons) {
 | 
				
			||||||
            if (directionButton.classList.contains("btn-primary")) {
 | 
					            if (directionButton.classList.contains("btn-primary")) {
 | 
				
			||||||
@@ -729,7 +675,7 @@ class GeneralTripTab extends TagTabPlane {
 | 
				
			|||||||
                break;
 | 
					                break;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.editor.summary.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
 | 
					        this.editor.description.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -757,13 +703,13 @@ class GeneralTripTab extends TagTabPlane {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Populates the tab plane with the summary input.
 | 
					     * Populates the tab plane with the description input.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return {boolean} true if the summary input matches this tab, or false otherwise
 | 
					     * @return {boolean} true if the description input matches this tab, or false otherwise
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    populate() {
 | 
					    populate() {
 | 
				
			||||||
        const found = this.editor.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
 | 
					        const found = this.editor.description.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
 | 
				
			||||||
        if (found === null) {
 | 
					        if (found === null) {
 | 
				
			||||||
            return false;
 | 
					            return false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -782,13 +728,6 @@ class GeneralTripTab extends TagTabPlane {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.#to.value = found[4];
 | 
					        this.#to.value = found[4];
 | 
				
			||||||
        for (const tagButton of this.tagButtons) {
 | 
					 | 
				
			||||||
            if (tagButton.dataset.value === this.tag.value) {
 | 
					 | 
				
			||||||
                tagButton.classList.remove("btn-outline-primary");
 | 
					 | 
				
			||||||
                tagButton.classList.add("btn-primary");
 | 
					 | 
				
			||||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        this.switchToMe();
 | 
					        this.switchToMe();
 | 
				
			||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -884,7 +823,7 @@ class BusTripTab extends TagTabPlane {
 | 
				
			|||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs a tab plane.
 | 
					     * Constructs a tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param editor {SummaryEditor} the parent summary editor
 | 
					     * @param editor {DescriptionEditor} the parent description editor
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(editor) {
 | 
					    constructor(editor) {
 | 
				
			||||||
@@ -897,17 +836,17 @@ class BusTripTab extends TagTabPlane {
 | 
				
			|||||||
        this.#toError = document.getElementById(this.prefix + "-to-error")
 | 
					        this.#toError = document.getElementById(this.prefix + "-to-error")
 | 
				
			||||||
        this.#route.onchange = () => {
 | 
					        this.#route.onchange = () => {
 | 
				
			||||||
            this.#route.value = this.#route.value.trim();
 | 
					            this.#route.value = this.#route.value.trim();
 | 
				
			||||||
            this.updateSummary();
 | 
					            this.updateDescription();
 | 
				
			||||||
            this.validateRoute();
 | 
					            this.validateRoute();
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
        this.#from.onchange = () => {
 | 
					        this.#from.onchange = () => {
 | 
				
			||||||
            this.#from.value = this.#from.value.trim();
 | 
					            this.#from.value = this.#from.value.trim();
 | 
				
			||||||
            this.updateSummary();
 | 
					            this.updateDescription();
 | 
				
			||||||
            this.validateFrom();
 | 
					            this.validateFrom();
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
        this.#to.onchange = () => {
 | 
					        this.#to.onchange = () => {
 | 
				
			||||||
            this.#to.value = this.#to.value.trim();
 | 
					            this.#to.value = this.#to.value.trim();
 | 
				
			||||||
            this.updateSummary();
 | 
					            this.updateDescription();
 | 
				
			||||||
            this.validateTo();
 | 
					            this.validateTo();
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -923,12 +862,12 @@ class BusTripTab extends TagTabPlane {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Updates the summary according to the input in the tab plane.
 | 
					     * Updates the description according to the input in the tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    updateSummary() {
 | 
					    updateDescription() {
 | 
				
			||||||
        this.editor.summary.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
 | 
					        this.editor.description.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -950,13 +889,13 @@ class BusTripTab extends TagTabPlane {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Populates the tab plane with the summary input.
 | 
					     * Populates the tab plane with the description input.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return {boolean} true if the summary input matches this tab, or false otherwise
 | 
					     * @return {boolean} true if the description input matches this tab, or false otherwise
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    populate() {
 | 
					    populate() {
 | 
				
			||||||
        const found = this.editor.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
 | 
					        const found = this.editor.description.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
 | 
				
			||||||
        if (found === null) {
 | 
					        if (found === null) {
 | 
				
			||||||
            return false;
 | 
					            return false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -967,14 +906,6 @@ class BusTripTab extends TagTabPlane {
 | 
				
			|||||||
        this.#route.value = found[2];
 | 
					        this.#route.value = found[2];
 | 
				
			||||||
        this.#from.value = found[3];
 | 
					        this.#from.value = found[3];
 | 
				
			||||||
        this.#to.value = found[4];
 | 
					        this.#to.value = found[4];
 | 
				
			||||||
        for (const tagButton of this.tagButtons) {
 | 
					 | 
				
			||||||
            if (tagButton.dataset.value === this.tag.value) {
 | 
					 | 
				
			||||||
                tagButton.classList.remove("btn-outline-primary");
 | 
					 | 
				
			||||||
                tagButton.classList.add("btn-primary");
 | 
					 | 
				
			||||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
					 | 
				
			||||||
                break;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        this.switchToMe();
 | 
					        this.switchToMe();
 | 
				
			||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -1035,29 +966,71 @@ class BusTripTab extends TagTabPlane {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * The regular payment tab plane.
 | 
					 * The recurring transaction tab plane.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * @private
 | 
					 * @private
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
class RegularPaymentTab extends TabPlane {
 | 
					class RecurringTransactionTab extends TabPlane {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The payment buttons
 | 
					     * The month names
 | 
				
			||||||
 | 
					     * @type {string[]}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #monthNames;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The buttons of the recurring items
 | 
				
			||||||
     * @type {HTMLButtonElement[]}
 | 
					     * @type {HTMLButtonElement[]}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    #payments;
 | 
					    #itemButtons;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // noinspection JSValidateTypes
 | 
					    // noinspection JSValidateTypes
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs a tab plane.
 | 
					     * Constructs a tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param editor {SummaryEditor} the parent summary editor
 | 
					     * @param editor {DescriptionEditor} the parent description editor
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(editor) {
 | 
					    constructor(editor) {
 | 
				
			||||||
        super(editor);
 | 
					        super(editor);
 | 
				
			||||||
 | 
					        this.#monthNames = [
 | 
				
			||||||
 | 
					            "",
 | 
				
			||||||
 | 
					            A_("January"), A_("February"), A_("March"), A_("April"),
 | 
				
			||||||
 | 
					            A_("May"), A_("June"), A_("July"), A_("August"),
 | 
				
			||||||
 | 
					            A_("September"), A_("October"), A_("November"), A_("December"),
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
        // noinspection JSValidateTypes
 | 
					        // noinspection JSValidateTypes
 | 
				
			||||||
        this.#payments = Array.from(document.getElementsByClassName(this.prefix + "-payment"));
 | 
					        this.#itemButtons = Array.from(document.getElementsByClassName(this.prefix + "-item"));
 | 
				
			||||||
 | 
					        for (const itemButton of this.#itemButtons) {
 | 
				
			||||||
 | 
					            itemButton.onclick = () => {
 | 
				
			||||||
 | 
					                this.reset();
 | 
				
			||||||
 | 
					                itemButton.classList.add("btn-primary");
 | 
				
			||||||
 | 
					                itemButton.classList.remove("btn-outline-primary");
 | 
				
			||||||
 | 
					                this.editor.description.value = this.#getDescription(itemButton);
 | 
				
			||||||
 | 
					                this.editor.filterSuggestedAccounts(itemButton);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Returns the description for a recurring item.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param itemButton {HTMLButtonElement} the recurring item
 | 
				
			||||||
 | 
					     * @return {string} the description of the recurring item
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #getDescription(itemButton) {
 | 
				
			||||||
 | 
					        const today = new Date(this.editor.lineItemEditor.form.getDate());
 | 
				
			||||||
 | 
					        const thisMonth = today.getMonth() + 1;
 | 
				
			||||||
 | 
					        const lastMonth = (thisMonth + 10) % 12 + 1;
 | 
				
			||||||
 | 
					        const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1);
 | 
				
			||||||
 | 
					        const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1);
 | 
				
			||||||
 | 
					        return itemButton.dataset.template
 | 
				
			||||||
 | 
					            .replaceAll("{this_month_number}", String(thisMonth))
 | 
				
			||||||
 | 
					            .replaceAll("{this_month_name}", this.#monthNames[thisMonth])
 | 
				
			||||||
 | 
					            .replaceAll("{last_month_number}", String(lastMonth))
 | 
				
			||||||
 | 
					            .replaceAll("{last_month_name}", this.#monthNames[lastMonth])
 | 
				
			||||||
 | 
					            .replaceAll("{last_bimonthly_number}", String(lastBimonthlyFrom) + "–" + String(lastBimonthlyTo))
 | 
				
			||||||
 | 
					            .replaceAll("{last_bimonthly_name}", this.#monthNames[lastBimonthlyFrom] + "–" + this.#monthNames[lastBimonthlyTo]);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -1067,7 +1040,7 @@ class RegularPaymentTab extends TabPlane {
 | 
				
			|||||||
     * @abstract
 | 
					     * @abstract
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    tabId() {
 | 
					    tabId() {
 | 
				
			||||||
        return "regular";
 | 
					        return "recurring";
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
@@ -1076,22 +1049,45 @@ class RegularPaymentTab extends TabPlane {
 | 
				
			|||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    reset() {
 | 
					    reset() {
 | 
				
			||||||
        for (const payment of this.#payments) {
 | 
					        for (const itemButton of this.#itemButtons) {
 | 
				
			||||||
            payment.classList.remove("btn-primary");
 | 
					            itemButton.classList.remove("btn-primary");
 | 
				
			||||||
            payment.classList.add("btn-outline-primary");
 | 
					            itemButton.classList.add("btn-outline-primary");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Populates the tab plane with the summary input.
 | 
					     * Populates the tab plane with the description input.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return {boolean} true if the summary input matches this tab, or false otherwise
 | 
					     * @return {boolean} true if the description input matches this tab, or false otherwise
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    populate() {
 | 
					    populate() {
 | 
				
			||||||
 | 
					        for (const itemButton of this.#itemButtons) {
 | 
				
			||||||
 | 
					            if (this.#getDescription(itemButton) === this.editor.description.value) {
 | 
				
			||||||
 | 
					                itemButton.classList.add("btn-primary");
 | 
				
			||||||
 | 
					                itemButton.classList.remove("btn-outline-primary");
 | 
				
			||||||
 | 
					                this.switchToMe();
 | 
				
			||||||
 | 
					                return true;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        return false;
 | 
					        return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Switches to the tab plane.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    switchToMe() {
 | 
				
			||||||
 | 
					        super.switchToMe();
 | 
				
			||||||
 | 
					        for (const itemButton of this.#itemButtons) {
 | 
				
			||||||
 | 
					            if (itemButton.classList.contains("btn-primary")) {
 | 
				
			||||||
 | 
					                this.editor.filterSuggestedAccounts(itemButton);
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.editor.clearSuggestedAccounts();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Validates the input in the tab plane.
 | 
					     * Validates the input in the tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
@@ -1113,15 +1109,15 @@ class AnnotationTab extends TabPlane {
 | 
				
			|||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Constructs a tab plane.
 | 
					     * Constructs a tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @param editor {SummaryEditor} the parent summary editor
 | 
					     * @param editor {DescriptionEditor} the parent description editor
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(editor) {
 | 
					    constructor(editor) {
 | 
				
			||||||
        super(editor);
 | 
					        super(editor);
 | 
				
			||||||
        this.editor.number.onchange = () => this.updateSummary();
 | 
					        this.editor.number.onchange = () => this.updateDescription();
 | 
				
			||||||
        this.editor.note.onchange = () => {
 | 
					        this.editor.note.onchange = () => {
 | 
				
			||||||
            this.editor.note.value = this.editor.note.value.trim();
 | 
					            this.editor.note.value = this.editor.note.value.trim();
 | 
				
			||||||
            this.updateSummary();
 | 
					            this.updateDescription();
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1136,20 +1132,20 @@ class AnnotationTab extends TabPlane {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Updates the summary according to the input in the tab plane.
 | 
					     * Updates the description according to the input in the tab plane.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    updateSummary() {
 | 
					    updateDescription() {
 | 
				
			||||||
        const found = this.editor.summary.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
 | 
					        const found = this.editor.description.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
 | 
				
			||||||
        if (found !== null) {
 | 
					        if (found !== null) {
 | 
				
			||||||
            this.editor.summary.value = found[1];
 | 
					            this.editor.description.value = found[1];
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (parseInt(this.editor.number.value) > 1) {
 | 
					        if (parseInt(this.editor.number.value) > 1) {
 | 
				
			||||||
            this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value;
 | 
					            this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (this.editor.note.value !== "") {
 | 
					        if (this.editor.note.value !== "") {
 | 
				
			||||||
            this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")";
 | 
					            this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1164,25 +1160,25 @@ class AnnotationTab extends TabPlane {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Populates the tab plane with the summary input.
 | 
					     * Populates the tab plane with the description input.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return {boolean} true if the summary input matches this tab, or false otherwise
 | 
					     * @return {boolean} true if the description input matches this tab, or false otherwise
 | 
				
			||||||
     * @override
 | 
					     * @override
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    populate() {
 | 
					    populate() {
 | 
				
			||||||
        const found = this.editor.summary.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
 | 
					        const found = this.editor.description.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
 | 
				
			||||||
        this.editor.summary.value = found[1];
 | 
					        this.editor.description.value = found[1];
 | 
				
			||||||
        if (found[2] === undefined || parseInt(found[2]) === 1) {
 | 
					        if (found[2] === undefined || parseInt(found[2]) === 1) {
 | 
				
			||||||
            this.editor.number.value = "";
 | 
					            this.editor.number.value = "";
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            this.editor.number.value = found[2];
 | 
					            this.editor.number.value = found[2];
 | 
				
			||||||
            this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value;
 | 
					            this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (found[3] === undefined) {
 | 
					        if (found[3] === undefined) {
 | 
				
			||||||
            this.editor.note.value = "";
 | 
					            this.editor.note.value = "";
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            this.editor.note.value = found[3];
 | 
					            this.editor.note.value = found[3];
 | 
				
			||||||
            this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")";
 | 
					            this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
							
								
								
									
										1056
									
								
								src/accounting/static/js/journal-entry-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1056
									
								
								src/accounting/static/js/journal-entry-form.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										596
									
								
								src/accounting/static/js/journal-entry-line-item-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										596
									
								
								src/accounting/static/js/journal-entry-line-item-editor.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,596 @@
 | 
				
			|||||||
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					 * journal-entry-line-item-editor.js: The JavaScript for the journal entry line item editor
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 *  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 *  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 *  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 *  limitations under the License.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					 * First written: 2023/2/25
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					"use strict";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * The journal entry line item editor.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					class JournalEntryLineItemEditor {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The journal entry form
 | 
				
			||||||
 | 
					     * @type {JournalEntryForm}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    form;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The journal entry line item editor
 | 
				
			||||||
 | 
					     * @type {HTMLFormElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #element;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The bootstrap modal
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #modal;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Either "debit" or "credit"
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    debitCredit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The prefix of the HTML ID and class
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #prefix = "accounting-line-item-editor"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The container of the original line item
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #originalLineItemContainer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The control of the original line item
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #originalLineItemControl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The original line item
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #originalLineItemText;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message of the original line item
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #originalLineItemError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The delete button of the original line item
 | 
				
			||||||
 | 
					     * @type {HTMLButtonElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #originalLineItemDelete;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The control of the description
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #descriptionControl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The description
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #descriptionText;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message of the description
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #descriptionError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The control of the account
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #accountControl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The account
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #accountText;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message of the account
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #accountError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The amount
 | 
				
			||||||
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #amountInput;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message of the amount
 | 
				
			||||||
 | 
					     * @type {HTMLDivElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #amountError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The journal entry line item to edit
 | 
				
			||||||
 | 
					     * @type {LineItemSubForm|null}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    lineItem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The debit or credit sub-form
 | 
				
			||||||
 | 
					     * @type {DebitCreditSubForm}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #debitCreditSubForm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Whether the journal entry line item needs offset
 | 
				
			||||||
 | 
					     * @type {boolean}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    isNeedOffset = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The ID of the original line item
 | 
				
			||||||
 | 
					     * @type {string|null}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    originalLineItemId = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The date of the original line item
 | 
				
			||||||
 | 
					     * @type {string|null}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    originalLineItemDate = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The text of the original line item
 | 
				
			||||||
 | 
					     * @type {string|null}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    originalLineItemText = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The account code
 | 
				
			||||||
 | 
					     * @type {string|null}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    accountCode = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The account text
 | 
				
			||||||
 | 
					     * @type {string|null}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    accountText = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The description
 | 
				
			||||||
 | 
					     * @type {string|null}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    description = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The amount
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    amount = "";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The description editors
 | 
				
			||||||
 | 
					     * @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #descriptionEditors;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The account selectors
 | 
				
			||||||
 | 
					     * @type {{debit: AccountSelector, credit: AccountSelector}}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #accountSelectors;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The original line item selector
 | 
				
			||||||
 | 
					     * @type {OriginalLineItemSelector}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    originalLineItemSelector;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Constructs a new journal entry line item editor.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param form {JournalEntryForm} the journal entry form
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    constructor(form) {
 | 
				
			||||||
 | 
					        this.form = form;
 | 
				
			||||||
 | 
					        this.#element = document.getElementById(this.#prefix);
 | 
				
			||||||
 | 
					        this.#modal = document.getElementById(this.#prefix + "-modal");
 | 
				
			||||||
 | 
					        this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
 | 
				
			||||||
 | 
					        this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
 | 
				
			||||||
 | 
					        this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
 | 
				
			||||||
 | 
					        this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error");
 | 
				
			||||||
 | 
					        this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete");
 | 
				
			||||||
 | 
					        this.#descriptionControl = document.getElementById(this.#prefix + "-description-control");
 | 
				
			||||||
 | 
					        this.#descriptionText = document.getElementById(this.#prefix + "-description");
 | 
				
			||||||
 | 
					        this.#descriptionError = document.getElementById(this.#prefix + "-description-error");
 | 
				
			||||||
 | 
					        this.#accountControl = document.getElementById(this.#prefix + "-account-control");
 | 
				
			||||||
 | 
					        this.#accountText = document.getElementById(this.#prefix + "-account");
 | 
				
			||||||
 | 
					        this.#accountError = document.getElementById(this.#prefix + "-account-error")
 | 
				
			||||||
 | 
					        this.#amountInput = document.getElementById(this.#prefix + "-amount");
 | 
				
			||||||
 | 
					        this.#amountError = document.getElementById(this.#prefix + "-amount-error");
 | 
				
			||||||
 | 
					        this.#descriptionEditors = DescriptionEditor.getInstances(this);
 | 
				
			||||||
 | 
					        this.#accountSelectors = AccountSelector.getInstances(this);
 | 
				
			||||||
 | 
					        this.originalLineItemSelector = new OriginalLineItemSelector(this);
 | 
				
			||||||
 | 
					        this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
 | 
				
			||||||
 | 
					        this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
 | 
				
			||||||
 | 
					        this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
 | 
				
			||||||
 | 
					        this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen();
 | 
				
			||||||
 | 
					        this.#amountInput.onchange = () => this.#validateAmount();
 | 
				
			||||||
 | 
					        this.#element.onsubmit = () => {
 | 
				
			||||||
 | 
					            if (this.#validate()) {
 | 
				
			||||||
 | 
					                if (this.lineItem === null) {
 | 
				
			||||||
 | 
					                    this.lineItem = this.#debitCreditSubForm.addLineItem();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                this.amount = this.#amountInput.value;
 | 
				
			||||||
 | 
					                this.lineItem.save(this);
 | 
				
			||||||
 | 
					                bootstrap.Modal.getInstance(this.#modal).hide();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Saves the original line item from the original line item selector.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param originalLineItem {OriginalLineItem} the original line item
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    saveOriginalLineItem(originalLineItem) {
 | 
				
			||||||
 | 
					        this.isNeedOffset = false;
 | 
				
			||||||
 | 
					        this.#originalLineItemContainer.classList.remove("d-none");
 | 
				
			||||||
 | 
					        this.#originalLineItemControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.originalLineItemId = originalLineItem.id;
 | 
				
			||||||
 | 
					        this.originalLineItemDate = originalLineItem.date;
 | 
				
			||||||
 | 
					        this.originalLineItemText = originalLineItem.text;
 | 
				
			||||||
 | 
					        this.#originalLineItemText.innerText = originalLineItem.text;
 | 
				
			||||||
 | 
					        this.#setEnableDescriptionAccount(false);
 | 
				
			||||||
 | 
					        if (originalLineItem.description === "") {
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.description = originalLineItem.description === ""? null: originalLineItem.description;
 | 
				
			||||||
 | 
					        this.#descriptionText.innerText = originalLineItem.description;
 | 
				
			||||||
 | 
					        this.#accountControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.accountCode = originalLineItem.accountCode;
 | 
				
			||||||
 | 
					        this.accountText = originalLineItem.accountText;
 | 
				
			||||||
 | 
					        this.#accountText.innerText = originalLineItem.accountText;
 | 
				
			||||||
 | 
					        this.#amountInput.value = String(originalLineItem.netBalance);
 | 
				
			||||||
 | 
					        this.#amountInput.max = String(originalLineItem.netBalance);
 | 
				
			||||||
 | 
					        this.#amountInput.min = "0";
 | 
				
			||||||
 | 
					        this.#validate();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Clears the original line item.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    clearOriginalLineItem() {
 | 
				
			||||||
 | 
					        this.isNeedOffset = false;
 | 
				
			||||||
 | 
					        this.#originalLineItemContainer.classList.add("d-none");
 | 
				
			||||||
 | 
					        this.#originalLineItemControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.originalLineItemId = null;
 | 
				
			||||||
 | 
					        this.originalLineItemDate = null;
 | 
				
			||||||
 | 
					        this.originalLineItemText = null;
 | 
				
			||||||
 | 
					        this.#originalLineItemText.innerText = "";
 | 
				
			||||||
 | 
					        this.#setEnableDescriptionAccount(true);
 | 
				
			||||||
 | 
					        this.#accountControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.accountCode = null;
 | 
				
			||||||
 | 
					        this.accountText = null;
 | 
				
			||||||
 | 
					        this.#accountText.innerText = "";
 | 
				
			||||||
 | 
					        this.#amountInput.max = "";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Returns the currency code.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return {string} the currency code
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getCurrencyCode() {
 | 
				
			||||||
 | 
					        return this.#debitCreditSubForm.currency.getCurrencyCode();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Saves the description from the description editor.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param description {string} the description
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    saveDescription(description) {
 | 
				
			||||||
 | 
					        if (description === "") {
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.description = description === ""? null: description;
 | 
				
			||||||
 | 
					        this.#descriptionText.innerText = description;
 | 
				
			||||||
 | 
					        this.#validateDescription();
 | 
				
			||||||
 | 
					        bootstrap.Modal.getOrCreateInstance(this.#modal).show();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Saves the description with the suggested account from the description editor.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param description {string} the description
 | 
				
			||||||
 | 
					     * @param accountCode {string} the account code
 | 
				
			||||||
 | 
					     * @param accountText {string} the account text
 | 
				
			||||||
 | 
					     * @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    saveDescriptionWithAccount(description, accountCode, accountText, isAccountNeedOffset) {
 | 
				
			||||||
 | 
					        this.isNeedOffset = isAccountNeedOffset;
 | 
				
			||||||
 | 
					        this.#accountControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.accountCode = accountCode;
 | 
				
			||||||
 | 
					        this.accountText = accountText;
 | 
				
			||||||
 | 
					        this.#accountText.innerText = accountText;
 | 
				
			||||||
 | 
					        this.#validateAccount();
 | 
				
			||||||
 | 
					        this.saveDescription(description)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Clears the account.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    clearAccount() {
 | 
				
			||||||
 | 
					        this.isNeedOffset = false;
 | 
				
			||||||
 | 
					        this.#accountControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.accountCode = null;
 | 
				
			||||||
 | 
					        this.accountText = null;
 | 
				
			||||||
 | 
					        this.#accountText.innerText = "";
 | 
				
			||||||
 | 
					        this.#validateAccount();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Sets the account.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param code {string} the account code
 | 
				
			||||||
 | 
					     * @param text {string} the account text
 | 
				
			||||||
 | 
					     * @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    saveAccount(code, text, isNeedOffset) {
 | 
				
			||||||
 | 
					        this.isNeedOffset = isNeedOffset;
 | 
				
			||||||
 | 
					        this.#accountControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.accountCode = code;
 | 
				
			||||||
 | 
					        this.accountText = text;
 | 
				
			||||||
 | 
					        this.#accountText.innerText = text;
 | 
				
			||||||
 | 
					        this.#validateAccount();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the form.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validate() {
 | 
				
			||||||
 | 
					        let isValid = true;
 | 
				
			||||||
 | 
					        isValid = this.#validateOriginalLineItem() && isValid;
 | 
				
			||||||
 | 
					        isValid = this.#validateDescription() && isValid;
 | 
				
			||||||
 | 
					        isValid = this.#validateAccount() && isValid;
 | 
				
			||||||
 | 
					        isValid = this.#validateAmount() && isValid
 | 
				
			||||||
 | 
					        return isValid;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the original line item.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     * @private
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validateOriginalLineItem() {
 | 
				
			||||||
 | 
					        this.#originalLineItemControl.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.#originalLineItemError.innerText = "";
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the description.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     * @private
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validateDescription() {
 | 
				
			||||||
 | 
					        this.#descriptionText.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.#descriptionError.innerText = "";
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the account.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validateAccount() {
 | 
				
			||||||
 | 
					        if (this.accountCode === null) {
 | 
				
			||||||
 | 
					            this.#accountControl.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#accountError.innerText = A_("Please select the account.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#accountControl.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.#accountError.innerText = "";
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Validates the amount.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					     * @private
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #validateAmount() {
 | 
				
			||||||
 | 
					        this.#amountInput.value = this.#amountInput.value.trim();
 | 
				
			||||||
 | 
					        this.#amountInput.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        if (this.#amountInput.value === "") {
 | 
				
			||||||
 | 
					            this.#amountInput.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#amountError.innerText = A_("Please fill in the amount.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        const amount =new Decimal(this.#amountInput.value);
 | 
				
			||||||
 | 
					        if (amount.lessThanOrEqualTo(0)) {
 | 
				
			||||||
 | 
					            this.#amountInput.classList.add("is-invalid");
 | 
				
			||||||
 | 
					            this.#amountError.innerText = A_("Please fill in a positive amount.");
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (this.#amountInput.max !== "") {
 | 
				
			||||||
 | 
					            if (amount.greaterThan(new Decimal(this.#amountInput.max))) {
 | 
				
			||||||
 | 
					                this.#amountInput.classList.add("is-invalid");
 | 
				
			||||||
 | 
					                this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original line item.", {balance: new Decimal(this.#amountInput.max)});
 | 
				
			||||||
 | 
					                return false;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (this.#amountInput.min !== "") {
 | 
				
			||||||
 | 
					            const min = new Decimal(this.#amountInput.min);
 | 
				
			||||||
 | 
					            if (amount.lessThan(min)) {
 | 
				
			||||||
 | 
					                this.#amountInput.classList.add("is-invalid");
 | 
				
			||||||
 | 
					                this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
 | 
				
			||||||
 | 
					                return false;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#amountInput.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.#amountError.innerText = "";
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The callback when adding a new journal entry line item.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param debitCredit {DebitCreditSubForm} the debit or credit sub-form
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    onAddNew(debitCredit) {
 | 
				
			||||||
 | 
					        this.lineItem = null;
 | 
				
			||||||
 | 
					        this.#debitCreditSubForm = debitCredit;
 | 
				
			||||||
 | 
					        this.debitCredit = this.#debitCreditSubForm.debitCredit;
 | 
				
			||||||
 | 
					        this.isNeedOffset = false;
 | 
				
			||||||
 | 
					        this.#originalLineItemContainer.classList.add("d-none");
 | 
				
			||||||
 | 
					        this.#originalLineItemControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.#originalLineItemControl.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.originalLineItemId = null;
 | 
				
			||||||
 | 
					        this.originalLineItemDate = null;
 | 
				
			||||||
 | 
					        this.originalLineItemText = null;
 | 
				
			||||||
 | 
					        this.#originalLineItemText.innerText = "";
 | 
				
			||||||
 | 
					        this.#setEnableDescriptionAccount(true);
 | 
				
			||||||
 | 
					        this.#descriptionControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.#descriptionControl.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.description = null;
 | 
				
			||||||
 | 
					        this.#descriptionText.innerText = ""
 | 
				
			||||||
 | 
					        this.#descriptionError.innerText = ""
 | 
				
			||||||
 | 
					        this.#accountControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        this.#accountControl.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.accountCode = null;
 | 
				
			||||||
 | 
					        this.accountText = null;
 | 
				
			||||||
 | 
					        this.#accountText.innerText = "";
 | 
				
			||||||
 | 
					        this.#accountError.innerText = "";
 | 
				
			||||||
 | 
					        this.#amountInput.value = "";
 | 
				
			||||||
 | 
					        this.#amountInput.max = "";
 | 
				
			||||||
 | 
					        this.#amountInput.min = "0";
 | 
				
			||||||
 | 
					        this.#amountInput.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        this.#amountError.innerText = "";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The callback when editing a journal entry line item.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param lineItem {LineItemSubForm} the journal entry line item sub-form
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    onEdit(lineItem) {
 | 
				
			||||||
 | 
					        this.lineItem = lineItem;
 | 
				
			||||||
 | 
					        this.#debitCreditSubForm = lineItem.debitCreditSubForm;
 | 
				
			||||||
 | 
					        this.debitCredit = this.#debitCreditSubForm.debitCredit;
 | 
				
			||||||
 | 
					        this.isNeedOffset = lineItem.isNeedOffset();
 | 
				
			||||||
 | 
					        this.originalLineItemId = lineItem.getOriginalLineItemId();
 | 
				
			||||||
 | 
					        this.originalLineItemDate = lineItem.getOriginalLineItemDate();
 | 
				
			||||||
 | 
					        this.originalLineItemText = lineItem.getOriginalLineItemText();
 | 
				
			||||||
 | 
					        this.#originalLineItemText.innerText = this.originalLineItemText;
 | 
				
			||||||
 | 
					        if (this.originalLineItemId === null) {
 | 
				
			||||||
 | 
					            this.#originalLineItemContainer.classList.add("d-none");
 | 
				
			||||||
 | 
					            this.#originalLineItemControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#originalLineItemContainer.classList.remove("d-none");
 | 
				
			||||||
 | 
					            this.#originalLineItemControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
 | 
				
			||||||
 | 
					        this.description = lineItem.getDescription();
 | 
				
			||||||
 | 
					        if (this.description === null) {
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#descriptionText.innerText = this.description === null? "": this.description;
 | 
				
			||||||
 | 
					        if (lineItem.getAccountCode() === null) {
 | 
				
			||||||
 | 
					            this.#accountControl.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#accountControl.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.accountCode = lineItem.getAccountCode();
 | 
				
			||||||
 | 
					        this.accountText = lineItem.getAccountText();
 | 
				
			||||||
 | 
					        this.#accountText.innerText = this.accountText;
 | 
				
			||||||
 | 
					        this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
 | 
				
			||||||
 | 
					        const maxAmount = this.#getMaxAmount();
 | 
				
			||||||
 | 
					        this.#amountInput.max = maxAmount === null? "": maxAmount;
 | 
				
			||||||
 | 
					        this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
 | 
				
			||||||
 | 
					        this.#validate();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Finds out the max amount.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return {Decimal|null} the max amount
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #getMaxAmount() {
 | 
				
			||||||
 | 
					        if (this.originalLineItemId === null) {
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return this.originalLineItemSelector.getNetBalance(this.lineItem, this.form, this.originalLineItemId);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Sets the enable status of the description and account.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param isEnabled {boolean} true to enable, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #setEnableDescriptionAccount(isEnabled) {
 | 
				
			||||||
 | 
					        if (isEnabled) {
 | 
				
			||||||
 | 
					            this.#descriptionControl.dataset.bsToggle = "modal";
 | 
				
			||||||
 | 
					            this.#descriptionControl.dataset.bsTarget = "#accounting-description-editor-" + this.#debitCreditSubForm.debitCredit + "-modal";
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.remove("accounting-disabled");
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.add("accounting-clickable");
 | 
				
			||||||
 | 
					            this.#accountControl.dataset.bsToggle = "modal";
 | 
				
			||||||
 | 
					            this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#debitCreditSubForm.debitCredit + "-modal";
 | 
				
			||||||
 | 
					            this.#accountControl.classList.remove("accounting-disabled");
 | 
				
			||||||
 | 
					            this.#accountControl.classList.add("accounting-clickable");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#descriptionControl.dataset.bsToggle = "";
 | 
				
			||||||
 | 
					            this.#descriptionControl.dataset.bsTarget = "";
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.add("accounting-disabled");
 | 
				
			||||||
 | 
					            this.#descriptionControl.classList.remove("accounting-clickable");
 | 
				
			||||||
 | 
					            this.#accountControl.dataset.bsToggle = "";
 | 
				
			||||||
 | 
					            this.#accountControl.dataset.bsTarget = "";
 | 
				
			||||||
 | 
					            this.#accountControl.classList.add("accounting-disabled");
 | 
				
			||||||
 | 
					            this.#accountControl.classList.remove("accounting-clickable");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
/* The Mia! Accounting Flask Project
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 * transaction-order.js: The JavaScript for the transaction order
 | 
					 * journal-entry-order.js: The JavaScript for the journal entry order
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*  Copyright (c) 2023 imacat.
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
							
								
								
									
										409
									
								
								src/accounting/static/js/original-line-item-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										409
									
								
								src/accounting/static/js/original-line-item-selector.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,409 @@
 | 
				
			|||||||
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					 * original-line-item-selector.js: The JavaScript for the original line item selector
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*  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/3/10
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					"use strict";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * The original line item selector.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					class OriginalLineItemSelector {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The line item editor
 | 
				
			||||||
 | 
					     * @type {JournalEntryLineItemEditor}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    lineItemEditor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The prefix of the HTML ID and class
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #prefix = "accounting-original-line-item-selector";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The query input
 | 
				
			||||||
 | 
					     * @type {HTMLInputElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #query;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The error message when the query has no result
 | 
				
			||||||
 | 
					     * @type {HTMLParagraphElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #queryNoResult;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The option list
 | 
				
			||||||
 | 
					     * @type {HTMLUListElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #optionList;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The options
 | 
				
			||||||
 | 
					     * @type {OriginalLineItem[]}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #options;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The options by their ID
 | 
				
			||||||
 | 
					     * @type {Object.<string, OriginalLineItem>}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #optionById;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The currency code
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #currencyCode;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Either "credit" or "debit"
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #debitCredit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Constructs an original line item selector.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    constructor(lineItemEditor) {
 | 
				
			||||||
 | 
					        this.lineItemEditor = lineItemEditor;
 | 
				
			||||||
 | 
					        this.#query = document.getElementById(this.#prefix + "-query");
 | 
				
			||||||
 | 
					        this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
 | 
				
			||||||
 | 
					        this.#optionList = document.getElementById(this.#prefix + "-option-list");
 | 
				
			||||||
 | 
					        this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalLineItem(this, element));
 | 
				
			||||||
 | 
					        this.#optionById = {};
 | 
				
			||||||
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
 | 
					            this.#optionById[option.id] = option;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#query.addEventListener("input", () => {
 | 
				
			||||||
 | 
					            this.#filterOptions();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Returns the net balance for an original line item.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param currentLineItem {LineItemSubForm} the line item sub-form that is currently editing
 | 
				
			||||||
 | 
					     * @param form {JournalEntryForm} the journal entry form
 | 
				
			||||||
 | 
					     * @param originalLineItemId {string} the ID of the original line item
 | 
				
			||||||
 | 
					     * @return {Decimal} the net balance of the original line item
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    getNetBalance(currentLineItem, form, originalLineItemId) {
 | 
				
			||||||
 | 
					        const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
 | 
				
			||||||
 | 
					        let otherOffset = new Decimal(0);
 | 
				
			||||||
 | 
					        for (const otherLineItem of otherLineItems) {
 | 
				
			||||||
 | 
					            if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
 | 
				
			||||||
 | 
					                const amount = otherLineItem.getAmount();
 | 
				
			||||||
 | 
					                if (amount !== null) {
 | 
				
			||||||
 | 
					                    otherOffset = otherOffset.plus(amount);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return this.#optionById[originalLineItemId].bareNetBalance.minus(otherOffset);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Updates the net balances, subtracting the offset amounts on the form but the currently editing line item
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #updateNetBalances() {
 | 
				
			||||||
 | 
					        const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
 | 
				
			||||||
 | 
					        const otherOffsets = {}
 | 
				
			||||||
 | 
					        for (const otherLineItem of otherLineItems) {
 | 
				
			||||||
 | 
					            const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
 | 
				
			||||||
 | 
					            const amount = otherLineItem.getAmount();
 | 
				
			||||||
 | 
					            if (otherOriginalLineItemId === null || amount === null) {
 | 
				
			||||||
 | 
					                continue;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            if (!(otherOriginalLineItemId in otherOffsets)) {
 | 
				
			||||||
 | 
					                otherOffsets[otherOriginalLineItemId] = new Decimal("0");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
 | 
					            if (option.id in otherOffsets) {
 | 
				
			||||||
 | 
					                option.updateNetBalance(otherOffsets[option.id]);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                option.resetNetBalance();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Filters the options.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #filterOptions() {
 | 
				
			||||||
 | 
					        let hasAnyMatched = false;
 | 
				
			||||||
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
 | 
					            if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
 | 
				
			||||||
 | 
					                option.setShown(true);
 | 
				
			||||||
 | 
					                hasAnyMatched = true;
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                option.setShown(false);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (!hasAnyMatched) {
 | 
				
			||||||
 | 
					            this.#optionList.classList.add("d-none");
 | 
				
			||||||
 | 
					            this.#queryNoResult.classList.remove("d-none");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#optionList.classList.remove("d-none");
 | 
				
			||||||
 | 
					            this.#queryNoResult.classList.add("d-none");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The callback when the original line item selector is shown.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    onOpen() {
 | 
				
			||||||
 | 
					        this.#currencyCode = this.lineItemEditor.getCurrencyCode();
 | 
				
			||||||
 | 
					        this.#debitCredit = this.lineItemEditor.debitCredit;
 | 
				
			||||||
 | 
					        for (const option of this.#options) {
 | 
				
			||||||
 | 
					            option.setActive(option.id === this.lineItemEditor.originalLineItemId);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#query.value = "";
 | 
				
			||||||
 | 
					        this.#updateNetBalances();
 | 
				
			||||||
 | 
					        this.#filterOptions();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * An original line item.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					class OriginalLineItem {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The original line item selector
 | 
				
			||||||
 | 
					     * @type {OriginalLineItemSelector}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #selector;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The element
 | 
				
			||||||
 | 
					     * @type {HTMLLIElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #element;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The ID
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The date
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    date;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Either "debit" or "credit"
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #debitCredit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The currency code
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #currencyCode;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The account code
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    accountCode;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The account text
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    accountText;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The description
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    description;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The net balance, without the offset amounts on the form
 | 
				
			||||||
 | 
					     * @type {Decimal}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    bareNetBalance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The net balance
 | 
				
			||||||
 | 
					     * @type {Decimal}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    netBalance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The text of the net balance
 | 
				
			||||||
 | 
					     * @type {HTMLSpanElement}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    netBalanceText;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The text representation of the original line item
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * The values to query against
 | 
				
			||||||
 | 
					     * @type {string[][]}
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #queryValues;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Constructs an original line item.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param selector {OriginalLineItemSelector} the original line item selector
 | 
				
			||||||
 | 
					     * @param element {HTMLLIElement} the element
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    constructor(selector, element) {
 | 
				
			||||||
 | 
					        this.#selector = selector;
 | 
				
			||||||
 | 
					        this.#element = element;
 | 
				
			||||||
 | 
					        this.id = element.dataset.id;
 | 
				
			||||||
 | 
					        this.date = element.dataset.date;
 | 
				
			||||||
 | 
					        this.#debitCredit = element.dataset.debitCredit;
 | 
				
			||||||
 | 
					        this.#currencyCode = element.dataset.currencyCode;
 | 
				
			||||||
 | 
					        this.accountCode = element.dataset.accountCode;
 | 
				
			||||||
 | 
					        this.accountText = element.dataset.accountText;
 | 
				
			||||||
 | 
					        this.description = element.dataset.description;
 | 
				
			||||||
 | 
					        this.bareNetBalance = new Decimal(element.dataset.netBalance);
 | 
				
			||||||
 | 
					        this.netBalance = this.bareNetBalance;
 | 
				
			||||||
 | 
					        this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance");
 | 
				
			||||||
 | 
					        this.text = element.dataset.text;
 | 
				
			||||||
 | 
					        this.#queryValues = JSON.parse(element.dataset.queryValues);
 | 
				
			||||||
 | 
					        this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(this);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Resets the net balance to its initial value, without the offset amounts on the form.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    resetNetBalance() {
 | 
				
			||||||
 | 
					        if (this.netBalance !== this.bareNetBalance) {
 | 
				
			||||||
 | 
					            this.netBalance = this.bareNetBalance;
 | 
				
			||||||
 | 
					            this.#updateNetBalanceText();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Updates the net balance with an offset.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param offset {Decimal} the offset to be added to the net balance
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    updateNetBalance(offset) {
 | 
				
			||||||
 | 
					        this.netBalance = this.bareNetBalance.minus(offset);
 | 
				
			||||||
 | 
					        this.#updateNetBalanceText();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Updates the text display of the net balance.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #updateNetBalanceText() {
 | 
				
			||||||
 | 
					        this.netBalanceText.innerText = formatDecimal(this.netBalance);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Returns whether the original matches.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param debitCredit {string} either "debit" or "credit"
 | 
				
			||||||
 | 
					     * @param currencyCode {string} the currency code
 | 
				
			||||||
 | 
					     * @param query {string|null} the query term
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    isMatched(debitCredit, currencyCode, query = null) {
 | 
				
			||||||
 | 
					        return this.netBalance.greaterThan(0)
 | 
				
			||||||
 | 
					            && this.date <= this.#selector.lineItemEditor.form.getDate()
 | 
				
			||||||
 | 
					            && this.#isDebitCreditMatches(debitCredit)
 | 
				
			||||||
 | 
					            && this.#currencyCode === currencyCode
 | 
				
			||||||
 | 
					            && this.#isQueryMatches(query);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Returns whether the original line item matches the debit or credit.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param debitCredit {string} either "debit" or credit
 | 
				
			||||||
 | 
					     * @return {boolean} true if the option matches, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #isDebitCreditMatches(debitCredit) {
 | 
				
			||||||
 | 
					        return (debitCredit === "debit" && this.#debitCredit === "credit")
 | 
				
			||||||
 | 
					            || (debitCredit === "credit" && this.#debitCredit === "debit");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Returns whether the original line item matches the query.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param query {string|null} the query term
 | 
				
			||||||
 | 
					     * @return {boolean} true if the option matches, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    #isQueryMatches(query) {
 | 
				
			||||||
 | 
					        if (query === "") {
 | 
				
			||||||
 | 
					            return true;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        for (const queryValue of this.#queryValues[0]) {
 | 
				
			||||||
 | 
					            if (queryValue.toLowerCase().includes(query.toLowerCase())) {
 | 
				
			||||||
 | 
					                return true;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        for (const queryValue of this.#queryValues[1]) {
 | 
				
			||||||
 | 
					            if (queryValue === query) {
 | 
				
			||||||
 | 
					                return true;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Sets whether the option is shown.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param isShown {boolean} true to show, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    setShown(isShown) {
 | 
				
			||||||
 | 
					        if (isShown) {
 | 
				
			||||||
 | 
					            this.#element.classList.remove("d-none");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#element.classList.add("d-none");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Sets whether the option is active.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @param isActive {boolean} true if active, or false otherwise
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    setActive(isActive) {
 | 
				
			||||||
 | 
					        if (isActive) {
 | 
				
			||||||
 | 
					            this.#element.classList.add("active");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.#element.classList.remove("active");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -175,7 +175,7 @@ class MonthTab extends TabPlane {
 | 
				
			|||||||
            let start = monthChooser.dataset.start;
 | 
					            let start = monthChooser.dataset.start;
 | 
				
			||||||
            this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
 | 
					            this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
 | 
				
			||||||
                restrictions: {
 | 
					                restrictions: {
 | 
				
			||||||
                    minDate: start,
 | 
					                    minDate: new Date(start),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                display: {
 | 
					                display: {
 | 
				
			||||||
                    inline: true,
 | 
					                    inline: true,
 | 
				
			||||||
@@ -184,7 +184,7 @@ class MonthTab extends TabPlane {
 | 
				
			|||||||
                        clock: false,
 | 
					                        clock: false,
 | 
				
			||||||
                    },
 | 
					                    },
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                defaultDate: monthChooser.dataset.default,
 | 
					                defaultDate: new Date(monthChooser.dataset.default),
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
            monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
 | 
					            monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
 | 
				
			||||||
                const date = e.detail.date;
 | 
					                const date = e.detail.date;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,680 +0,0 @@
 | 
				
			|||||||
/* The Mia! Accounting Flask Project
 | 
					 | 
				
			||||||
 * transaction-transfer-form.js: The JavaScript for the transfer transaction form
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/*  Copyright (c) 2023 imacat.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
					 | 
				
			||||||
 *  you may not use this file except in compliance with the License.
 | 
					 | 
				
			||||||
 *  You may obtain a copy of the License at
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 *  Unless required by applicable law or agreed to in writing, software
 | 
					 | 
				
			||||||
 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
					 | 
				
			||||||
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
					 | 
				
			||||||
 *  See the License for the specific language governing permissions and
 | 
					 | 
				
			||||||
 *  limitations under the License.
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					 | 
				
			||||||
 * First written: 2023/2/25
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
"use strict";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Initializes the page JavaScript.
 | 
					 | 
				
			||||||
document.addEventListener("DOMContentLoaded", () => {
 | 
					 | 
				
			||||||
    initializeCurrencyForms();
 | 
					 | 
				
			||||||
    initializeJournalEntries();
 | 
					 | 
				
			||||||
    initializeFormValidation();
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Escapes the HTML special characters and returns.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param s {string} the original string
 | 
					 | 
				
			||||||
 * @returns {string} the string with HTML special character escaped
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function escapeHtml(s) {
 | 
					 | 
				
			||||||
    return String(s)
 | 
					 | 
				
			||||||
         .replaceAll("&", "&")
 | 
					 | 
				
			||||||
         .replaceAll("<", "<")
 | 
					 | 
				
			||||||
         .replaceAll(">", ">")
 | 
					 | 
				
			||||||
         .replaceAll("\"", """);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Formats a Decimal number.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param number {Decimal} the Decimal number
 | 
					 | 
				
			||||||
 * @returns {string} the formatted Decimal number
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function formatDecimal(number) {
 | 
					 | 
				
			||||||
    if (number.equals(new Decimal("0"))) {
 | 
					 | 
				
			||||||
        return "-";
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    const frac = number.modulo(1);
 | 
					 | 
				
			||||||
    const whole = Number(number.minus(frac)).toLocaleString();
 | 
					 | 
				
			||||||
    return whole + String(frac).substring(1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the currency forms.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeCurrencyForms() {
 | 
					 | 
				
			||||||
    const form = document.getElementById("accounting-form");
 | 
					 | 
				
			||||||
    const btnNew = document.getElementById("accounting-btn-new-currency");
 | 
					 | 
				
			||||||
    const currencyList = document.getElementById("accounting-currency-list");
 | 
					 | 
				
			||||||
    const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
 | 
					 | 
				
			||||||
    const onReorder = () => {
 | 
					 | 
				
			||||||
        const currencies = Array.from(currencyList.children);
 | 
					 | 
				
			||||||
        for (let i = 0; i < currencies.length; i++) {
 | 
					 | 
				
			||||||
            const no = document.getElementById(currencies[i].dataset.prefix + "-no");
 | 
					 | 
				
			||||||
            no.value = String(i + 1);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    btnNew.onclick = () => {
 | 
					 | 
				
			||||||
        const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
 | 
					 | 
				
			||||||
        let maxIndex = 0;
 | 
					 | 
				
			||||||
        for (const currency of currencies) {
 | 
					 | 
				
			||||||
            const index = parseInt(currency.dataset.index);
 | 
					 | 
				
			||||||
            if (maxIndex < index) {
 | 
					 | 
				
			||||||
                maxIndex = index;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        const newIndex = String(maxIndex + 1);
 | 
					 | 
				
			||||||
        const html = form.dataset.currencyTemplate
 | 
					 | 
				
			||||||
            .replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
 | 
					 | 
				
			||||||
        currencyList.insertAdjacentHTML("beforeend", html);
 | 
					 | 
				
			||||||
        const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry"));
 | 
					 | 
				
			||||||
        const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex);
 | 
					 | 
				
			||||||
        newEntryButtons.forEach(initializeNewEntryButton);
 | 
					 | 
				
			||||||
        initializeBtnDeleteCurrency(btnDelete);
 | 
					 | 
				
			||||||
        resetDeleteCurrencyButtons();
 | 
					 | 
				
			||||||
        initializeDragAndDropReordering(currencyList, onReorder);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    deleteButtons.forEach(initializeBtnDeleteCurrency);
 | 
					 | 
				
			||||||
    initializeDragAndDropReordering(currencyList, onReorder);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the button to delete a currency.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param button {HTMLButtonElement} the button to delete a currency.
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeBtnDeleteCurrency(button) {
 | 
					 | 
				
			||||||
    const target = document.getElementById(button.dataset.target);
 | 
					 | 
				
			||||||
    button.onclick = () => {
 | 
					 | 
				
			||||||
        target.parentElement.removeChild(target);
 | 
					 | 
				
			||||||
        resetDeleteCurrencyButtons();
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Resets the status of the delete currency buttons.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function resetDeleteCurrencyButtons() {
 | 
					 | 
				
			||||||
    const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
 | 
					 | 
				
			||||||
    if (buttons.length > 1) {
 | 
					 | 
				
			||||||
        for (const button of buttons) {
 | 
					 | 
				
			||||||
            button.classList.remove("d-none");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        buttons[0].classList.add("d-none");
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the journal entry forms.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeJournalEntries() {
 | 
					 | 
				
			||||||
    const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry"));
 | 
					 | 
				
			||||||
    const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list"));
 | 
					 | 
				
			||||||
    const entries = Array.from(document.getElementsByClassName("accounting-entry"))
 | 
					 | 
				
			||||||
    const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry"));
 | 
					 | 
				
			||||||
    newButtons.forEach(initializeNewEntryButton);
 | 
					 | 
				
			||||||
    entryLists.forEach(initializeJournalEntryListReorder);
 | 
					 | 
				
			||||||
    entries.forEach(initializeJournalEntry);
 | 
					 | 
				
			||||||
    deleteButtons.forEach(initializeDeleteJournalEntryButton);
 | 
					 | 
				
			||||||
    initializeJournalEntryFormModal();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the button to add a new journal entry.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param button {HTMLButtonElement} the button to add a new journal entry
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeNewEntryButton(button) {
 | 
					 | 
				
			||||||
    const entryForm = document.getElementById("accounting-entry-form");
 | 
					 | 
				
			||||||
    const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
					 | 
				
			||||||
    const formAccount = document.getElementById("accounting-entry-form-account");
 | 
					 | 
				
			||||||
    const formAccountError = document.getElementById("accounting-entry-form-account-error")
 | 
					 | 
				
			||||||
    const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
					 | 
				
			||||||
    const formSummary = document.getElementById("accounting-entry-form-summary");
 | 
					 | 
				
			||||||
    const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
 | 
					 | 
				
			||||||
    const formAmount = document.getElementById("accounting-entry-form-amount");
 | 
					 | 
				
			||||||
    const formAmountError = document.getElementById("accounting-entry-form-amount-error");
 | 
					 | 
				
			||||||
    button.onclick = () => {
 | 
					 | 
				
			||||||
        entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
 | 
					 | 
				
			||||||
        entryForm.dataset.entryType = button.dataset.entryType;
 | 
					 | 
				
			||||||
        entryForm.dataset.entryIndex = button.dataset.entryIndex;
 | 
					 | 
				
			||||||
        formAccountControl.classList.remove("accounting-not-empty");
 | 
					 | 
				
			||||||
        formAccountControl.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
        formAccount.innerText = "";
 | 
					 | 
				
			||||||
        formAccount.dataset.code = "";
 | 
					 | 
				
			||||||
        formAccount.dataset.text = "";
 | 
					 | 
				
			||||||
        formAccountError.innerText = "";
 | 
					 | 
				
			||||||
        formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + button.dataset.entryType + "-modal";
 | 
					 | 
				
			||||||
        formSummaryControl.classList.remove("accounting-not-empty");
 | 
					 | 
				
			||||||
        formSummaryControl.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
        formSummary.dataset.value = "";
 | 
					 | 
				
			||||||
        formSummary.innerText = ""
 | 
					 | 
				
			||||||
        formSummaryError.innerText = ""
 | 
					 | 
				
			||||||
        formAmount.value = "";
 | 
					 | 
				
			||||||
        formAmount.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
        formAmountError.innerText = "";
 | 
					 | 
				
			||||||
        AccountSelector.initializeJournalEntryForm();
 | 
					 | 
				
			||||||
        SummaryEditor.initializeNewJournalEntry(button.dataset.entryType);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the reordering of a journal entry list.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param entryList {HTMLUListElement} the journal entry list.
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeJournalEntryListReorder(entryList) {
 | 
					 | 
				
			||||||
    initializeDragAndDropReordering(entryList, () => {
 | 
					 | 
				
			||||||
        const entries = Array.from(entryList.children);
 | 
					 | 
				
			||||||
        for (let i = 0; i < entries.length; i++) {
 | 
					 | 
				
			||||||
            const no = document.getElementById(entries[i].dataset.prefix + "-no");
 | 
					 | 
				
			||||||
            no.value = String(i + 1);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the journal entry.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param entry {HTMLLIElement} the journal entry.
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeJournalEntry(entry) {
 | 
					 | 
				
			||||||
    const entryForm = document.getElementById("accounting-entry-form");
 | 
					 | 
				
			||||||
    const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
 | 
					 | 
				
			||||||
    const summary = document.getElementById(entry.dataset.prefix + "-summary");
 | 
					 | 
				
			||||||
    const amount = document.getElementById(entry.dataset.prefix + "-amount");
 | 
					 | 
				
			||||||
    const control = document.getElementById(entry.dataset.prefix + "-control");
 | 
					 | 
				
			||||||
    const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
					 | 
				
			||||||
    const formAccount = document.getElementById("accounting-entry-form-account");
 | 
					 | 
				
			||||||
    const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
					 | 
				
			||||||
    const formSummary = document.getElementById("accounting-entry-form-summary");
 | 
					 | 
				
			||||||
    const formAmount = document.getElementById("accounting-entry-form-amount");
 | 
					 | 
				
			||||||
    control.onclick = () => {
 | 
					 | 
				
			||||||
        entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
 | 
					 | 
				
			||||||
        entryForm.dataset.entryType = entry.dataset.entryType;
 | 
					 | 
				
			||||||
        entryForm.dataset.entryIndex = entry.dataset.entryIndex;
 | 
					 | 
				
			||||||
        if (accountCode.value === "") {
 | 
					 | 
				
			||||||
            formAccountControl.classList.remove("accounting-not-empty");
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            formAccountControl.classList.add("accounting-not-empty");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        formAccount.innerText = accountCode.dataset.text;
 | 
					 | 
				
			||||||
        formAccount.dataset.code = accountCode.value;
 | 
					 | 
				
			||||||
        formAccount.dataset.text = accountCode.dataset.text;
 | 
					 | 
				
			||||||
        formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.dataset.entryType + "-modal";
 | 
					 | 
				
			||||||
        if (summary.value === "") {
 | 
					 | 
				
			||||||
            formSummaryControl.classList.remove("accounting-not-empty");
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            formSummaryControl.classList.add("accounting-not-empty");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        formSummary.dataset.value = summary.value;
 | 
					 | 
				
			||||||
        formSummary.innerText = summary.value;
 | 
					 | 
				
			||||||
        formAmount.value = amount.value;
 | 
					 | 
				
			||||||
        AccountSelector.initializeJournalEntryForm();
 | 
					 | 
				
			||||||
        validateJournalEntryForm();
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the journal entry form modal.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeJournalEntryFormModal() {
 | 
					 | 
				
			||||||
    const entryForm = document.getElementById("accounting-entry-form");
 | 
					 | 
				
			||||||
    const formAmount = document.getElementById("accounting-entry-form-amount");
 | 
					 | 
				
			||||||
    const modal = document.getElementById("accounting-entry-form-modal");
 | 
					 | 
				
			||||||
    formAmount.onchange = validateJournalEntryAmount;
 | 
					 | 
				
			||||||
    entryForm.onsubmit = () => {
 | 
					 | 
				
			||||||
        if (validateJournalEntryForm()) {
 | 
					 | 
				
			||||||
            saveJournalEntryForm();
 | 
					 | 
				
			||||||
            bootstrap.Modal.getInstance(modal).hide();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the journal entry form modal.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if the form is valid, or false otherwise.
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateJournalEntryForm() {
 | 
					 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    isValid = validateJournalEntryAccount() && isValid;
 | 
					 | 
				
			||||||
    isValid = validateJournalEntrySummary() && isValid;
 | 
					 | 
				
			||||||
    isValid = validateJournalEntryAmount() && isValid
 | 
					 | 
				
			||||||
    return isValid;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the account in the journal entry form modal.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateJournalEntryAccount() {
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-entry-form-account");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-entry-form-account-error");
 | 
					 | 
				
			||||||
    const control = document.getElementById("accounting-entry-form-account-control");
 | 
					 | 
				
			||||||
    if (field.dataset.code === "") {
 | 
					 | 
				
			||||||
        control.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please select the account.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    control.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the summary in the journal entry form modal.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateJournalEntrySummary() {
 | 
					 | 
				
			||||||
    const control = document.getElementById("accounting-entry-form-summary-control");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-entry-form-summary-error");
 | 
					 | 
				
			||||||
    control.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the amount in the journal entry form modal.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateJournalEntryAmount() {
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-entry-form-amount");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-entry-form-amount-error");
 | 
					 | 
				
			||||||
    field.value = field.value.trim();
 | 
					 | 
				
			||||||
    field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    if (field.value === "") {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please fill in the amount.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Saves the journal entry form modal to the form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function saveJournalEntryForm() {
 | 
					 | 
				
			||||||
    const form = document.getElementById("accounting-form");
 | 
					 | 
				
			||||||
    const entryForm = document.getElementById("accounting-entry-form");
 | 
					 | 
				
			||||||
    const formAccount = document.getElementById("accounting-entry-form-account");
 | 
					 | 
				
			||||||
    const formSummary = document.getElementById("accounting-entry-form-summary");
 | 
					 | 
				
			||||||
    const formAmount = document.getElementById("accounting-entry-form-amount");
 | 
					 | 
				
			||||||
    const currencyIndex = entryForm.dataset.currencyIndex;
 | 
					 | 
				
			||||||
    const entryType = entryForm.dataset.entryType;
 | 
					 | 
				
			||||||
    let entryIndex;
 | 
					 | 
				
			||||||
    if (entryForm.dataset.entryIndex === "new") {
 | 
					 | 
				
			||||||
        const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
 | 
					 | 
				
			||||||
        const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
 | 
					 | 
				
			||||||
        let maxIndex = 0;
 | 
					 | 
				
			||||||
        for (const entry of entries) {
 | 
					 | 
				
			||||||
            const index = parseInt(entry.dataset.entryIndex);
 | 
					 | 
				
			||||||
            if (maxIndex < index) {
 | 
					 | 
				
			||||||
                maxIndex = index;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        entryIndex = String(maxIndex + 1);
 | 
					 | 
				
			||||||
        const html = form.dataset.entryTemplate
 | 
					 | 
				
			||||||
            .replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
 | 
					 | 
				
			||||||
            .replaceAll("ENTRY_TYPE", escapeHtml(entryType))
 | 
					 | 
				
			||||||
            .replaceAll("ENTRY_INDEX", escapeHtml(entryIndex));
 | 
					 | 
				
			||||||
        entryList.insertAdjacentHTML("beforeend", html);
 | 
					 | 
				
			||||||
        initializeJournalEntryListReorder(entryList);
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        entryIndex = entryForm.dataset.entryIndex;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    const currency = document.getElementById("accounting-currency-" + currencyIndex);
 | 
					 | 
				
			||||||
    const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex);
 | 
					 | 
				
			||||||
    const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
 | 
					 | 
				
			||||||
    const accountText = document.getElementById(entry.dataset.prefix + "-account-text");
 | 
					 | 
				
			||||||
    const summary = document.getElementById(entry.dataset.prefix + "-summary");
 | 
					 | 
				
			||||||
    const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text");
 | 
					 | 
				
			||||||
    const amount = document.getElementById(entry.dataset.prefix + "-amount");
 | 
					 | 
				
			||||||
    const amountText = document.getElementById(entry.dataset.prefix + "-amount-text");
 | 
					 | 
				
			||||||
    accountCode.value = formAccount.dataset.code;
 | 
					 | 
				
			||||||
    accountCode.dataset.text = formAccount.dataset.text;
 | 
					 | 
				
			||||||
    accountText.innerText = formAccount.dataset.text;
 | 
					 | 
				
			||||||
    summary.value = formSummary.dataset.value;
 | 
					 | 
				
			||||||
    summaryText.innerText = formSummary.dataset.value;
 | 
					 | 
				
			||||||
    amount.value = formAmount.value;
 | 
					 | 
				
			||||||
    amountText.innerText = formatDecimal(new Decimal(formAmount.value));
 | 
					 | 
				
			||||||
    if (entryForm.dataset.entryIndex === "new") {
 | 
					 | 
				
			||||||
        const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete");
 | 
					 | 
				
			||||||
        initializeJournalEntry(entry);
 | 
					 | 
				
			||||||
        initializeDeleteJournalEntryButton(btnDelete);
 | 
					 | 
				
			||||||
        resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    updateBalance(currencyIndex, entryType);
 | 
					 | 
				
			||||||
    validateJournalEntriesReal(currencyIndex, entryType);
 | 
					 | 
				
			||||||
    validateBalance(currency);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the button to delete a journal entry.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param button {HTMLButtonElement} the button to delete a journal entry
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeDeleteJournalEntryButton(button) {
 | 
					 | 
				
			||||||
    const target = document.getElementById(button.dataset.target);
 | 
					 | 
				
			||||||
    const currencyIndex = target.dataset.currencyIndex;
 | 
					 | 
				
			||||||
    const entryType = target.dataset.entryType;
 | 
					 | 
				
			||||||
    const currency = document.getElementById("accounting-currency-" + currencyIndex);
 | 
					 | 
				
			||||||
    button.onclick = () => {
 | 
					 | 
				
			||||||
        target.parentElement.removeChild(target);
 | 
					 | 
				
			||||||
        resetDeleteJournalEntryButtons(button.dataset.sameClass);
 | 
					 | 
				
			||||||
        updateBalance(currencyIndex, entryType);
 | 
					 | 
				
			||||||
        validateJournalEntriesReal(currencyIndex, entryType);
 | 
					 | 
				
			||||||
        validateBalance(currency);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Resets the status of the delete journal entry buttons.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param sameClass {string} the class of the buttons
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function resetDeleteJournalEntryButtons(sameClass) {
 | 
					 | 
				
			||||||
    const buttons = Array.from(document.getElementsByClassName(sameClass));
 | 
					 | 
				
			||||||
    if (buttons.length > 1) {
 | 
					 | 
				
			||||||
        for (const button of buttons) {
 | 
					 | 
				
			||||||
            button.classList.remove("d-none");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        buttons[0].classList.add("d-none");
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Updates the balance.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param currencyIndex {string} the currency index.
 | 
					 | 
				
			||||||
 * @param entryType {string} the journal entry type, either "debit" or "credit"
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function updateBalance(currencyIndex, entryType) {
 | 
					 | 
				
			||||||
    const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
 | 
					 | 
				
			||||||
    const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
 | 
					 | 
				
			||||||
    const totalText = document.getElementById(prefix + "-total");
 | 
					 | 
				
			||||||
    let total = new Decimal("0");
 | 
					 | 
				
			||||||
    for (const amount of amounts) {
 | 
					 | 
				
			||||||
        if (amount.value !== "") {
 | 
					 | 
				
			||||||
            total = total.plus(new Decimal(amount.value));
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    totalText.innerText = formatDecimal(total);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Initializes the form validation.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function initializeFormValidation() {
 | 
					 | 
				
			||||||
    const date = document.getElementById("accounting-date");
 | 
					 | 
				
			||||||
    const note = document.getElementById("accounting-note");
 | 
					 | 
				
			||||||
    const form = document.getElementById("accounting-form");
 | 
					 | 
				
			||||||
    date.onchange = validateDate;
 | 
					 | 
				
			||||||
    note.onchange = validateNote;
 | 
					 | 
				
			||||||
    form.onsubmit = validateForm;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateForm() {
 | 
					 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    isValid = validateDate() && isValid;
 | 
					 | 
				
			||||||
    isValid = validateCurrencies() && isValid;
 | 
					 | 
				
			||||||
    isValid = validateNote() && isValid;
 | 
					 | 
				
			||||||
    return isValid;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the date.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateDate() {
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-date");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-date-error");
 | 
					 | 
				
			||||||
    field.value = field.value.trim();
 | 
					 | 
				
			||||||
    field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    if (field.value === "") {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please fill in the date.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the currency sub-forms.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateCurrencies() {
 | 
					 | 
				
			||||||
    const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
 | 
					 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    isValid = validateCurrenciesReal() && isValid;
 | 
					 | 
				
			||||||
    for (const currency of currencies) {
 | 
					 | 
				
			||||||
        isValid = validateCurrency(currency) && isValid;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return isValid;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the currency sub-forms, the validator itself.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateCurrenciesReal() {
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-currencies");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-currencies-error");
 | 
					 | 
				
			||||||
    const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
 | 
					 | 
				
			||||||
    if (currencies.length === 0) {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please add some currencies.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates a currency sub-form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param currency {HTMLDivElement} the currency sub-form
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateCurrency(currency) {
 | 
					 | 
				
			||||||
    const prefix = "accounting-currency-" + currency.dataset.index;
 | 
					 | 
				
			||||||
    const debit = document.getElementById(prefix + "-debit");
 | 
					 | 
				
			||||||
    const credit = document.getElementById(prefix + "-credit");
 | 
					 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    if (debit !== null) {
 | 
					 | 
				
			||||||
        isValid = validateJournalEntries(currency, "debit") && isValid;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (credit !== null) {
 | 
					 | 
				
			||||||
        isValid = validateJournalEntries(currency, "credit") && isValid;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (debit !== null && credit !== null) {
 | 
					 | 
				
			||||||
        isValid = validateBalance(currency) && isValid;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return isValid;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the journal entries in a currency sub-form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param currency {HTMLDivElement} the currency
 | 
					 | 
				
			||||||
 * @param entryType {string} the journal entry type, either "debit" or "credit"
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateJournalEntries(currency, entryType) {
 | 
					 | 
				
			||||||
    const currencyIndex = currency.dataset.index;
 | 
					 | 
				
			||||||
    const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
 | 
					 | 
				
			||||||
    let isValid = true;
 | 
					 | 
				
			||||||
    isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
 | 
					 | 
				
			||||||
    for (const entry of entries) {
 | 
					 | 
				
			||||||
        isValid = validateJournalEntry(entry) && isValid;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return isValid;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the journal entries, the validator itself.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param currencyIndex {string} the currency index
 | 
					 | 
				
			||||||
 * @param entryType {string} the journal entry type, either "debit" or "credit"
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateJournalEntriesReal(currencyIndex, entryType) {
 | 
					 | 
				
			||||||
    const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
 | 
					 | 
				
			||||||
    const field = document.getElementById(prefix);
 | 
					 | 
				
			||||||
    const error = document.getElementById(prefix + "-error");
 | 
					 | 
				
			||||||
    const entries = Array.from(document.getElementsByClassName(prefix));
 | 
					 | 
				
			||||||
    if (entries.length === 0) {
 | 
					 | 
				
			||||||
        field.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please add some journal entries.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates a journal entry sub-form in a currency sub-form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param entry {HTMLLIElement} the journal entry
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateJournalEntry(entry) {
 | 
					 | 
				
			||||||
    const control = document.getElementById(entry.dataset.prefix + "-control");
 | 
					 | 
				
			||||||
    const error = document.getElementById(entry.dataset.prefix + "-error");
 | 
					 | 
				
			||||||
    const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
 | 
					 | 
				
			||||||
    const amount = document.getElementById(entry.dataset.prefix + "-amount");
 | 
					 | 
				
			||||||
    if (accountCode.value === "") {
 | 
					 | 
				
			||||||
        control.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please select the account.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (amount.value === "") {
 | 
					 | 
				
			||||||
        control.classList.add("is-invalid");
 | 
					 | 
				
			||||||
        error.innerText = A_("Please fill in the amount.");
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    control.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the balance of a currency sub-form.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param currency {HTMLDivElement} the currency sub-form
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateBalance(currency) {
 | 
					 | 
				
			||||||
    const prefix = "accounting-currency-" + currency.dataset.index;
 | 
					 | 
				
			||||||
    const control = document.getElementById(prefix + "-control");
 | 
					 | 
				
			||||||
    const error = document.getElementById(prefix + "-error");
 | 
					 | 
				
			||||||
    const debit = document.getElementById(prefix + "-debit");
 | 
					 | 
				
			||||||
    const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount"));
 | 
					 | 
				
			||||||
    const credit = document.getElementById(prefix + "-credit");
 | 
					 | 
				
			||||||
    const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
 | 
					 | 
				
			||||||
    if (debit !== null && credit !== null) {
 | 
					 | 
				
			||||||
        let debitTotal = new Decimal("0");
 | 
					 | 
				
			||||||
        for (const amount of debitAmounts) {
 | 
					 | 
				
			||||||
            if (amount.value !== "") {
 | 
					 | 
				
			||||||
                debitTotal = debitTotal.plus(new Decimal(amount.value));
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        let creditTotal = new Decimal("0");
 | 
					 | 
				
			||||||
        for (const amount of creditAmounts) {
 | 
					 | 
				
			||||||
            if (amount.value !== "") {
 | 
					 | 
				
			||||||
                creditTotal = creditTotal.plus(new Decimal(amount.value));
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        if (!debitTotal.equals(creditTotal)) {
 | 
					 | 
				
			||||||
            control.classList.add("is-invalid");
 | 
					 | 
				
			||||||
            error.innerText = A_("The totals of the debit and credit amounts do not match.");
 | 
					 | 
				
			||||||
            return false;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    control.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Validates the note.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @return {boolean} true if valid, or false otherwise
 | 
					 | 
				
			||||||
 * @private
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function validateNote() {
 | 
					 | 
				
			||||||
    const field = document.getElementById("accounting-note");
 | 
					 | 
				
			||||||
    const error = document.getElementById("accounting-note-error");
 | 
					 | 
				
			||||||
    field.value = field.value
 | 
					 | 
				
			||||||
         .replace(/^\s*\n/, "")
 | 
					 | 
				
			||||||
         .trimEnd();
 | 
					 | 
				
			||||||
    field.classList.remove("is-invalid");
 | 
					 | 
				
			||||||
    error.innerText = "";
 | 
					 | 
				
			||||||
    return true;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
#  See the License for the specific language governing permissions and
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
#  limitations under the License.
 | 
					#  limitations under the License.
 | 
				
			||||||
"""The template globals for the transaction management.
 | 
					"""The template globals.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
from flask import current_app
 | 
					from flask import current_app
 | 
				
			||||||
@@ -35,5 +35,4 @@ def default_currency_code() -> str:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    :return: The default currency code.
 | 
					    :return: The default currency code.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    with current_app.app_context():
 | 
					    return current_app.config.get("ACCOUNTING_DEFAULT_CURRENCY", "USD")
 | 
				
			||||||
        return current_app.config.get("DEFAULT_CURRENCY", "USD")
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,26 +25,33 @@ First written: 2023/1/31
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 accounting-toolbar">
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
					  <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
  {% if accounting_can_edit() %}
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
					    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
				
			||||||
      <i class="fa-solid fa-gear"></i>
 | 
					      <i class="fa-solid fa-pen-to-square"></i>
 | 
				
			||||||
      {{ A_("Settings") }}
 | 
					      {{ A_("Edit") }}
 | 
				
			||||||
    </a>
 | 
					    </a>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
					    <i class="fa-solid fa-bars-staggered"></i>
 | 
				
			||||||
    {{ A_("Order") }}
 | 
					    <span class="d-none d-md-inline">{{ A_("Order") }}</span>
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
  {% if accounting_can_edit() %}
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
					    {% if obj.can_delete %}
 | 
				
			||||||
      <i class="fa-solid fa-trash"></i>
 | 
					      <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
				
			||||||
      {{ A_("Delete") }}
 | 
					        <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
    </button>
 | 
					        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    {% else %}
 | 
				
			||||||
 | 
					      <button class="btn btn-secondary" type="button" disabled="disabled">
 | 
				
			||||||
 | 
					        <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
 | 
					        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -56,7 +63,7 @@ First written: 2023/1/31
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% if accounting_can_edit() %}
 | 
					{% if accounting_can_edit() and obj.can_delete %}
 | 
				
			||||||
  <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
 | 
					  <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
 | 
				
			||||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
					    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
    {% if request.args.next %}
 | 
					    {% if request.args.next %}
 | 
				
			||||||
@@ -66,7 +73,7 @@ First written: 2023/1/31
 | 
				
			|||||||
      <div class="modal-dialog">
 | 
					      <div class="modal-dialog">
 | 
				
			||||||
        <div class="modal-content">
 | 
					        <div class="modal-content">
 | 
				
			||||||
          <div class="modal-header">
 | 
					          <div class="modal-header">
 | 
				
			||||||
            <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1>
 | 
					            <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Account") }}</h1>
 | 
				
			||||||
            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
					            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="modal-body">
 | 
					          <div class="modal-body">
 | 
				
			||||||
@@ -83,11 +90,11 @@ First written: 2023/1/31
 | 
				
			|||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="accounting-card col-sm-6">
 | 
					<div class="accounting-card col-sm-6">
 | 
				
			||||||
  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
					  <div class="accounting-card-title">{{ obj.title|title }}</div>
 | 
				
			||||||
  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
					  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
				
			||||||
  {% if obj.is_offset_needed %}
 | 
					  {% if obj.is_need_offset %}
 | 
				
			||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
      <span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
 | 
					      <span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  <div class="small text-secondary fst-italic">
 | 
					  <div class="small text-secondary fst-italic">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,7 +27,7 @@ First written: 2023/2/1
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 d-none d-md-block">
 | 
				
			||||||
  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
					  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
@@ -41,9 +41,9 @@ First written: 2023/2/1
 | 
				
			|||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  <div class="form-floating mb-3">
 | 
					  <div class="form-floating mb-3">
 | 
				
			||||||
    <input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
 | 
					    <input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
 | 
				
			||||||
    <div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
 | 
					    <div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
 | 
				
			||||||
      <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
 | 
					      <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
 | 
				
			||||||
      <div id="accounting-base-content">
 | 
					      <div id="accounting-base">
 | 
				
			||||||
        {% if form.base_code.data %}
 | 
					        {% if form.base_code.data %}
 | 
				
			||||||
          {% if form.base_code.errors %}
 | 
					          {% if form.base_code.errors %}
 | 
				
			||||||
            {{ A_("(Unknown)") }}
 | 
					            {{ A_("(Unknown)") }}
 | 
				
			||||||
@@ -53,7 +53,7 @@ First written: 2023/2/1
 | 
				
			|||||||
        {% endif %}
 | 
					        {% endif %}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
 | 
					    <div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="form-floating mb-3">
 | 
					  <div class="form-floating mb-3">
 | 
				
			||||||
@@ -62,10 +62,10 @@ First written: 2023/2/1
 | 
				
			|||||||
    <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
 | 
					    <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="form-check form-switch mb-3">
 | 
					  <div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
 | 
				
			||||||
    <input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
 | 
					    <input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}>
 | 
				
			||||||
    <label class="form-check-label" for="accounting-is-offset-needed">
 | 
					    <label class="form-check-label" for="accounting-is-need-offset">
 | 
				
			||||||
      {{ A_("The entries in the account need offset.") }}
 | 
					      {{ A_("The line items in the account need offset.") }}
 | 
				
			||||||
    </label>
 | 
					    </label>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -99,21 +99,21 @@ First written: 2023/2/1
 | 
				
			|||||||
          </label>
 | 
					          </label>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <ul id="accounting-base-option-list" class="list-group accounting-selector-list">
 | 
					        <ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
 | 
				
			||||||
          {% for base in form.base_options %}
 | 
					          {% for base in form.base_options %}
 | 
				
			||||||
          <li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
 | 
					            <li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
 | 
				
			||||||
            {{ base }}
 | 
					              {{ base }}
 | 
				
			||||||
          </li>
 | 
					            </li>
 | 
				
			||||||
          {% endfor %}
 | 
					          {% endfor %}
 | 
				
			||||||
        </ul>
 | 
					        </ul>
 | 
				
			||||||
        <p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
					        <p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div class="modal-footer">
 | 
					      <div class="modal-footer">
 | 
				
			||||||
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
					        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
				
			||||||
        {% if form.base_code.data %}
 | 
					        {% if form.base_code.data %}
 | 
				
			||||||
          <button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
 | 
					          <button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
 | 
				
			||||||
        {% else %}
 | 
					        {% else %}
 | 
				
			||||||
          <button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
 | 
					          <button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
 | 
				
			||||||
        {% endif %}
 | 
					        {% endif %}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -58,8 +58,8 @@ First written: 2023/1/30
 | 
				
			|||||||
  {% for item in list %}
 | 
					  {% for item in list %}
 | 
				
			||||||
    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
 | 
					    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
 | 
				
			||||||
      {{ item }}
 | 
					      {{ item }}
 | 
				
			||||||
      {% if item.is_offset_needed %}
 | 
					      {% if item.is_need_offset %}
 | 
				
			||||||
        <span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
 | 
					        <span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span>
 | 
				
			||||||
      {% endif %}
 | 
					      {% endif %}
 | 
				
			||||||
    </a>
 | 
					    </a>
 | 
				
			||||||
  {% endfor %}
 | 
					  {% endfor %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -30,7 +30,7 @@ First written: 2023/2/2
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 d-none d-md-block">
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,7 +25,7 @@ First written: 2023/2/1
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 d-none d-md-block">
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
@@ -33,7 +33,7 @@ First written: 2023/2/1
 | 
				
			|||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="accounting-card col-sm-6">
 | 
					<div class="accounting-card col-sm-6">
 | 
				
			||||||
  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
					  <div class="accounting-card-title">{{ obj.title|title }}</div>
 | 
				
			||||||
  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
					  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
				
			||||||
  {% if obj.accounts %}
 | 
					  {% if obj.accounts %}
 | 
				
			||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,22 +25,29 @@ First written: 2023/2/6
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 accounting-toolbar">
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
 | 
					  <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
  {% if accounting_can_edit() %}
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
					    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
				
			||||||
      <i class="fa-solid fa-gear"></i>
 | 
					      <i class="fa-solid fa-pen-to-square"></i>
 | 
				
			||||||
      {{ A_("Settings") }}
 | 
					      {{ A_("Edit") }}
 | 
				
			||||||
    </a>
 | 
					    </a>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  {% if accounting_can_edit() %}
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
					    {% if obj.can_delete %}
 | 
				
			||||||
      <i class="fa-solid fa-trash"></i>
 | 
					      <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
				
			||||||
      {{ A_("Delete") }}
 | 
					        <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
    </button>
 | 
					        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    {% else %}
 | 
				
			||||||
 | 
					      <button class="btn btn-secondary" type="button" disabled="disabled">
 | 
				
			||||||
 | 
					        <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
 | 
					        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -52,7 +59,7 @@ First written: 2023/2/6
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% if accounting_can_edit() %}
 | 
					{% if accounting_can_edit() and obj.can_delete %}
 | 
				
			||||||
  <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
 | 
					  <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
 | 
				
			||||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
					    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
    {% if request.args.next %}
 | 
					    {% if request.args.next %}
 | 
				
			||||||
@@ -62,7 +69,7 @@ First written: 2023/2/6
 | 
				
			|||||||
      <div class="modal-dialog">
 | 
					      <div class="modal-dialog">
 | 
				
			||||||
        <div class="modal-content">
 | 
					        <div class="modal-content">
 | 
				
			||||||
          <div class="modal-header">
 | 
					          <div class="modal-header">
 | 
				
			||||||
            <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1>
 | 
					            <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Currency") }}</h1>
 | 
				
			||||||
            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
					            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="modal-body">
 | 
					          <div class="modal-body">
 | 
				
			||||||
@@ -79,7 +86,7 @@ First written: 2023/2/6
 | 
				
			|||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="accounting-card col-sm-6">
 | 
					<div class="accounting-card col-sm-6">
 | 
				
			||||||
  <div class="accounting-card-title">{{ obj.name }}</div>
 | 
					  <div class="accounting-card-title">{{ obj.name|title }}</div>
 | 
				
			||||||
  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
					  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
				
			||||||
  <div class="small text-secondary fst-italic">
 | 
					  <div class="small text-secondary fst-italic">
 | 
				
			||||||
    <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
 | 
					    <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,7 +27,7 @@ First written: 2023/2/6
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 d-none d-md-block">
 | 
				
			||||||
  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
					  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,25 +28,25 @@ First written: 2023/1/26
 | 
				
			|||||||
    </span>
 | 
					    </span>
 | 
				
			||||||
    <ul class="dropdown-menu">
 | 
					    <ul class="dropdown-menu">
 | 
				
			||||||
      <li>
 | 
					      <li>
 | 
				
			||||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
 | 
					        <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
 | 
				
			||||||
          <i class="fa-solid fa-book"></i>
 | 
					          <i class="fa-solid fa-book"></i>
 | 
				
			||||||
          {{ A_("Reports") }}
 | 
					          {{ A_("Reports") }}
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
      <li>
 | 
					      <li>
 | 
				
			||||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
 | 
					        <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
 | 
				
			||||||
          <i class="fa-solid fa-clipboard"></i>
 | 
					          <i class="fa-solid fa-clipboard"></i>
 | 
				
			||||||
          {{ A_("Accounts") }}
 | 
					          {{ A_("Accounts") }}
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
      <li>
 | 
					      <li>
 | 
				
			||||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
 | 
					        <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
 | 
				
			||||||
          <i class="fa-solid fa-list"></i>
 | 
					          <i class="fa-solid fa-list"></i>
 | 
				
			||||||
          {{ A_("Base Accounts") }}
 | 
					          {{ A_("Base Accounts") }}
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
      <li>
 | 
					      <li>
 | 
				
			||||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
 | 
					        <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
 | 
				
			||||||
          <i class="fa-solid fa-money-bill-wave"></i>
 | 
					          <i class="fa-solid fa-money-bill-wave"></i>
 | 
				
			||||||
          {{ A_("Currencies") }}
 | 
					          {{ A_("Currencies") }}
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
create.html: The cash expense transaction creation form
 | 
					create.html: The cash disbursement journal entry creation form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,10 +19,10 @@ create.html: The cash expense transaction creation form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/expense/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/disbursement/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
 | 
					{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
 | 
					{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
 | 
					{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
detail.html: The account detail
 | 
					detail.html: The cash disbursement journal entry detail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,36 +19,26 @@ detail.html: The account detail
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/26
 | 
					First written: 2023/2/26
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/include/detail.html" %}
 | 
					{% extends "accounting/journal-entry/include/detail.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block to_transfer %}
 | 
					{% block as_trasfer %}
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
					    <i class="fa-solid fa-table-columns"></i>
 | 
				
			||||||
    {{ A_("To Transfer") }}
 | 
					    <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block transaction_currencies %}
 | 
					{% block journal_entry_currencies %}
 | 
				
			||||||
  {% for currency in obj.currencies %}
 | 
					  {% for currency in obj.currencies %}
 | 
				
			||||||
    <div class="mb-3">
 | 
					    <div class="mb-3">
 | 
				
			||||||
      <div class="mb-2 fw-bolder">{{ currency.name }}</div>
 | 
					      <div class="mb-2 fw-bolder">{{ currency.name }}</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
 | 
					      <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
 | 
				
			||||||
        <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
 | 
					        <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
 | 
				
			||||||
        {% for entry in currency.credit %}
 | 
					        {% with line_items = currency.debit %}
 | 
				
			||||||
          <li class="list-group-item accounting-transaction-entry">
 | 
					          {% include "accounting/journal-entry/include/detail-line-items.html" %}
 | 
				
			||||||
            <div class="d-flex justify-content-between">
 | 
					        {% endwith %}
 | 
				
			||||||
              <div>
 | 
					        <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
 | 
				
			||||||
                <div class="small">{{ entry.account }}</div>
 | 
					 | 
				
			||||||
                {% if entry.summary is not none %}
 | 
					 | 
				
			||||||
                  <div>{{ entry.summary }}</div>
 | 
					 | 
				
			||||||
                {% endif %}
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
              <div>{{ entry.amount|accounting_format_amount }}</div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </li>
 | 
					 | 
				
			||||||
        {% endfor %}
 | 
					 | 
				
			||||||
        <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
 | 
					 | 
				
			||||||
          <div class="d-flex justify-content-between">
 | 
					          <div class="d-flex justify-content-between">
 | 
				
			||||||
            <div>{{ A_("Total") }}</div>
 | 
					            <div>{{ A_("Total") }}</div>
 | 
				
			||||||
            <div>{{ currency.debit_total|accounting_format_amount }}</div>
 | 
					            <div>{{ currency.debit_total|accounting_format_amount }}</div>
 | 
				
			||||||
@@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					edit.html: The cash disbursement journal entry edit form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/25
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/journal-entry/disbursement/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
edit.html: The cash income transaction edit form
 | 
					form-currency.html: The currency sub-form in the cash disbursement journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,10 +19,15 @@ edit.html: The cash income transaction edit form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/income/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/include/form-currency.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
 | 
					{% block line_items %}
 | 
				
			||||||
 | 
					  {% with currency_index = currency_index,
 | 
				
			||||||
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
 | 
					          debit_credit = "debit",
 | 
				
			||||||
 | 
					          line_item_forms = debit_forms,
 | 
				
			||||||
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
 | 
					          header = A_("Content"),
 | 
				
			||||||
 | 
					          debit_credit_total = debit_total,
 | 
				
			||||||
 | 
					          debit_credit_errors = debit_errors %}
 | 
				
			||||||
 | 
					    {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
				
			||||||
 | 
					  {% endwith %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
form.html: The cash expense transaction form
 | 
					form.html: The cash disbursement journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,7 +19,7 @@ form.html: The cash expense transaction form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block currency_sub_forms %}
 | 
					{% block currency_sub_forms %}
 | 
				
			||||||
  {% if form.currencies %}
 | 
					  {% if form.currencies %}
 | 
				
			||||||
@@ -29,10 +29,11 @@ First written: 2023/2/25
 | 
				
			|||||||
              currency_errors = currency_form.whole_form.errors,
 | 
					              currency_errors = currency_form.whole_form.errors,
 | 
				
			||||||
              currency_code_data = currency_form.code.data,
 | 
					              currency_code_data = currency_form.code.data,
 | 
				
			||||||
              currency_code_errors = currency_form.code.errors,
 | 
					              currency_code_errors = currency_form.code.errors,
 | 
				
			||||||
 | 
					              currency_code_is_locked = currency_form.is_code_locked,
 | 
				
			||||||
              debit_forms = currency_form.debit,
 | 
					              debit_forms = currency_form.debit,
 | 
				
			||||||
              debit_errors = currency_form.debit_errors,
 | 
					              debit_errors = currency_form.debit_errors,
 | 
				
			||||||
              debit_total = currency_form.form.debit_total|accounting_format_amount %}
 | 
					              debit_total = currency_form.debit_total|accounting_format_amount %}
 | 
				
			||||||
        {% include "accounting/transaction/expense/include/form-currency-item.html" %}
 | 
					        {% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
 | 
				
			||||||
      {% endwith %}
 | 
					      {% endwith %}
 | 
				
			||||||
    {% endfor %}
 | 
					    {% endfor %}
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
@@ -40,17 +41,17 @@ First written: 2023/2/25
 | 
				
			|||||||
            only_one_currency_form = True,
 | 
					            only_one_currency_form = True,
 | 
				
			||||||
            currency_code_data = accounting_default_currency_code(),
 | 
					            currency_code_data = accounting_default_currency_code(),
 | 
				
			||||||
            debit_total = "-" %}
 | 
					            debit_total = "-" %}
 | 
				
			||||||
      {% include "accounting/transaction/expense/include/form-currency-item.html" %}
 | 
					      {% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
 | 
				
			||||||
    {% endwith %}
 | 
					    {% endwith %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block form_modals %}
 | 
					{% block form_modals %}
 | 
				
			||||||
  {% with summary_editor = form.summary_editor.debit %}
 | 
					  {% with description_editor = form.description_editor.debit %}
 | 
				
			||||||
    {% include "accounting/transaction/include/summary-editor-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/description-editor-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
  {% with entry_type = "debit",
 | 
					  {% with debit_credit = "debit",
 | 
				
			||||||
          account_options = form.debit_account_options %}
 | 
					          account_options = form.debit_account_options %}
 | 
				
			||||||
    {% include "accounting/transaction/include/account-selector-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/account-selector-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
@@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					account-selector-modal.html: The modal for the account selector
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/25
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					<div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-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-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1>
 | 
				
			||||||
 | 
					        <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="modal-body">
 | 
				
			||||||
 | 
					        <div class="input-group mb-2">
 | 
				
			||||||
 | 
					          <input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
 | 
				
			||||||
 | 
					          <label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query">
 | 
				
			||||||
 | 
					            <i class="fa-solid fa-magnifying-glass"></i>
 | 
				
			||||||
 | 
					            {{ A_("Search") }}
 | 
				
			||||||
 | 
					          </label>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
 | 
				
			||||||
 | 
					          {% for account in account_options %}
 | 
				
			||||||
 | 
					            <li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
				
			||||||
 | 
					              {{ account }}
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          {% endfor %}
 | 
				
			||||||
 | 
					          <li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					        <p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="modal-footer">
 | 
				
			||||||
 | 
					        <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
 | 
				
			||||||
 | 
					        <button id="accounting-account-selector-{{ debit_credit }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Clear") }}</button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@@ -0,0 +1,199 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					description-editor-modal.html: The modal of the description editor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/28
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
 | 
				
			||||||
 | 
					  <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
 | 
				
			||||||
 | 
					    <div class="modal-dialog">
 | 
				
			||||||
 | 
					      <div class="modal-content">
 | 
				
			||||||
 | 
					        <div class="modal-header">
 | 
				
			||||||
 | 
					          <h1 class="modal-title fs-5" id="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
 | 
				
			||||||
 | 
					            <label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label>
 | 
				
			||||||
 | 
					          </h1>
 | 
				
			||||||
 | 
					          <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="modal-body">
 | 
				
			||||||
 | 
					          <div class="d-flex justify-content-between mb-3">
 | 
				
			||||||
 | 
					            <input id="accounting-description-editor-{{ description_editor.debit_credit }}-description" class="form-control" type="text" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
 | 
				
			||||||
 | 
					            <button id="accounting-description-editor-{{ description_editor.debit_credit }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
 | 
				
			||||||
 | 
					              {{ A_("Offset...") }}
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {# Tab navigation #}
 | 
				
			||||||
 | 
					          <ul class="nav nav-tabs mb-2">
 | 
				
			||||||
 | 
					            <li class="nav-item">
 | 
				
			||||||
 | 
					              <span id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
 | 
				
			||||||
 | 
					                {{ A_("General") }}
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					            <li class="nav-item">
 | 
				
			||||||
 | 
					              <span id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
 | 
				
			||||||
 | 
					                {{ A_("Travel") }}
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					            <li class="nav-item">
 | 
				
			||||||
 | 
					              <span id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
 | 
				
			||||||
 | 
					                {{ A_("Bus") }}
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					            <li class="nav-item">
 | 
				
			||||||
 | 
					              <span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false">
 | 
				
			||||||
 | 
					                {{ A_("Recurring") }}
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					            <li class="nav-item">
 | 
				
			||||||
 | 
					              <span id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
 | 
				
			||||||
 | 
					                {{ A_("Annotation") }}
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {# A general description with a tag #}
 | 
				
			||||||
 | 
					          <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
 | 
				
			||||||
 | 
					            <div class="form-floating mb-2">
 | 
				
			||||||
 | 
					              <input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					              <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label>
 | 
				
			||||||
 | 
					              <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="accounting-description-editor-buttons">
 | 
				
			||||||
 | 
					              {% for tag in description_editor.general.tags %}
 | 
				
			||||||
 | 
					                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
 | 
				
			||||||
 | 
					                  {{ tag }}
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              {% endfor %}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {# A general trip with the origin and distination #}
 | 
				
			||||||
 | 
					          <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
 | 
				
			||||||
 | 
					            <div class="form-floating mb-2">
 | 
				
			||||||
 | 
					              <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					              <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label>
 | 
				
			||||||
 | 
					              <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="accounting-description-editor-buttons">
 | 
				
			||||||
 | 
					              {% for tag in description_editor.travel.tags %}
 | 
				
			||||||
 | 
					                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
 | 
				
			||||||
 | 
					                  {{ tag }}
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              {% endfor %}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					              <div class="form-floating">
 | 
				
			||||||
 | 
					                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
 | 
				
			||||||
 | 
					                <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div class="btn-group-vertical ms-1 me-1">
 | 
				
			||||||
 | 
					                <button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button>
 | 
				
			||||||
 | 
					                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div class="form-floating">
 | 
				
			||||||
 | 
					                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label>
 | 
				
			||||||
 | 
					                <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {# A bus trip with the route name or route number, the origin and distination #}
 | 
				
			||||||
 | 
					          <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
 | 
				
			||||||
 | 
					            <div class="d-flex justify-content-between mb-2">
 | 
				
			||||||
 | 
					              <div class="form-floating me-2">
 | 
				
			||||||
 | 
					                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label>
 | 
				
			||||||
 | 
					                <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div class="form-floating">
 | 
				
			||||||
 | 
					                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label>
 | 
				
			||||||
 | 
					                <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="accounting-description-editor-buttons">
 | 
				
			||||||
 | 
					              {% for tag in description_editor.bus.tags %}
 | 
				
			||||||
 | 
					                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
 | 
				
			||||||
 | 
					                  {{ tag }}
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              {% endfor %}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					              <div class="form-floating me-2">
 | 
				
			||||||
 | 
					                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
 | 
				
			||||||
 | 
					                <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div class="form-floating">
 | 
				
			||||||
 | 
					                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label>
 | 
				
			||||||
 | 
					                <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {# A recurring transaction #}
 | 
				
			||||||
 | 
					          <div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
 | 
				
			||||||
 | 
					            <div class="accounting-description-editor-buttons">
 | 
				
			||||||
 | 
					              {% for recurring in description_editor.recurring %}
 | 
				
			||||||
 | 
					                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
 | 
				
			||||||
 | 
					                  {{ recurring.name }}
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              {% endfor %}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {# The annotation #}
 | 
				
			||||||
 | 
					          <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
 | 
				
			||||||
 | 
					            <div class="form-floating">
 | 
				
			||||||
 | 
					              <input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
 | 
				
			||||||
 | 
					              <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label>
 | 
				
			||||||
 | 
					              <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="form-floating mt-2">
 | 
				
			||||||
 | 
					              <input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
 | 
				
			||||||
 | 
					              <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label>
 | 
				
			||||||
 | 
					              <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {# The suggested accounts #}
 | 
				
			||||||
 | 
					          <div class="mt-3">
 | 
				
			||||||
 | 
					            {% for account in description_editor.accounts %}
 | 
				
			||||||
 | 
					              <button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
 | 
				
			||||||
 | 
					                {{ account }}
 | 
				
			||||||
 | 
					              </button>
 | 
				
			||||||
 | 
					            {% endfor %}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="modal-footer">
 | 
				
			||||||
 | 
					          <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
 | 
				
			||||||
 | 
					          <button id="accounting-description-editor-{{ description_editor.debit_credit }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</form>
 | 
				
			||||||
@@ -0,0 +1,75 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					detail-line-items-item: The line items in the journal entry detail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 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/3/14
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{# <ul> For SonarQube not to complain about incorrect HTML #}
 | 
				
			||||||
 | 
					{% for line_item in line_items %}
 | 
				
			||||||
 | 
					  <li class="list-group-item accounting-journal-entry-line-item">
 | 
				
			||||||
 | 
					    <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        <div class="small">{{ line_item.account }}</div>
 | 
				
			||||||
 | 
					        {% if line_item.description is not none %}
 | 
				
			||||||
 | 
					          <div>{{ line_item.description }}</div>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					        {% if line_item.original_line_item %}
 | 
				
			||||||
 | 
					          <div class="fst-italic small accounting-original-line-item">
 | 
				
			||||||
 | 
					            <a href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.original_line_item.journal_entry)|accounting_append_next }}">
 | 
				
			||||||
 | 
					              {{ A_("Offset %(item)s", item=line_item.original_line_item) }}
 | 
				
			||||||
 | 
					            </a>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					        {% if line_item.is_need_offset %}
 | 
				
			||||||
 | 
					          <div class="fst-italic small accounting-offset-line-items">
 | 
				
			||||||
 | 
					            {% if line_item.offsets %}
 | 
				
			||||||
 | 
					              <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					                <div>{{ A_("Offsets") }}</div>
 | 
				
			||||||
 | 
					                <ul class="ms-2 ps-0">
 | 
				
			||||||
 | 
					                  {% for offset in line_item.offsets %}
 | 
				
			||||||
 | 
					                    <li>
 | 
				
			||||||
 | 
					                      <a href="{{ url_for("accounting.journal-entry.detail", journal_entry=offset.journal_entry)|accounting_append_next }}">
 | 
				
			||||||
 | 
					                        {{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
 | 
				
			||||||
 | 
					                      </a>
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                  {% endfor %}
 | 
				
			||||||
 | 
					                </ul>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              {% if line_item.balance %}
 | 
				
			||||||
 | 
					                <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					                  <div>{{ A_("Net balance") }}</div>
 | 
				
			||||||
 | 
					                  <div>{{ line_item.balance|accounting_format_amount }}</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              {% else %}
 | 
				
			||||||
 | 
					                <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					                  <div>{{ A_("Fully offset") }}</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					            {% else %}
 | 
				
			||||||
 | 
					              <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					                {{ A_("Unmatched") }}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div>{{ line_item.amount|accounting_format_amount }}</div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </li>
 | 
				
			||||||
 | 
					{% endfor %}
 | 
				
			||||||
 | 
					{# </ul> For SonarQube not to complain about incorrect HTML #}
 | 
				
			||||||
@@ -25,40 +25,47 @@ First written: 2023/2/26
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 accounting-toolbar">
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
					  <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
  {% if accounting_can_edit() %}
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
 | 
					    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
 | 
				
			||||||
      <i class="fa-solid fa-gear"></i>
 | 
					      <i class="fa-solid fa-pen-to-square"></i>
 | 
				
			||||||
      {{ A_("Settings") }}
 | 
					      {{ A_("Edit") }}
 | 
				
			||||||
    </a>
 | 
					    </a>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}">
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
					    <i class="fa-solid fa-bars-staggered"></i>
 | 
				
			||||||
    {{ A_("Order") }}
 | 
					    <span class="d-none d-md-inline">{{ A_("Order") }}</span>
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
  {% if accounting_can_edit() %}
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
    {% block to_transfer %}{% endblock %}
 | 
					    {% block as_trasfer %}{% endblock %}
 | 
				
			||||||
    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
					    {% if obj.can_delete %}
 | 
				
			||||||
      <i class="fa-solid fa-trash"></i>
 | 
					      <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
				
			||||||
      {{ A_("Delete") }}
 | 
					        <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
    </button>
 | 
					        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    {% else %}
 | 
				
			||||||
 | 
					      <button class="btn btn-secondary" type="button" disabled="disabled">
 | 
				
			||||||
 | 
					        <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
 | 
					        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% if accounting_can_edit() %}
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
  <div class="d-md-none accounting-material-fab">
 | 
					  <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
    <a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
 | 
					    <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
 | 
				
			||||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
					      <i class="fa-solid fa-pen-to-square"></i>
 | 
				
			||||||
    </a>
 | 
					    </a>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% if accounting_can_edit() %}
 | 
					{% if accounting_can_edit() and obj.can_delete %}
 | 
				
			||||||
  <form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
 | 
					  <form action="{{ url_for("accounting.journal-entry.delete", journal_entry=obj) }}" method="post">
 | 
				
			||||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
					    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
    {% if request.args.next %}
 | 
					    {% if request.args.next %}
 | 
				
			||||||
      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
					      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
				
			||||||
@@ -67,11 +74,11 @@ First written: 2023/2/26
 | 
				
			|||||||
      <div class="modal-dialog">
 | 
					      <div class="modal-dialog">
 | 
				
			||||||
        <div class="modal-content">
 | 
					        <div class="modal-content">
 | 
				
			||||||
          <div class="modal-header">
 | 
					          <div class="modal-header">
 | 
				
			||||||
            <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1>
 | 
					            <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Journal Entry") }}</h1>
 | 
				
			||||||
            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
					            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="modal-body">
 | 
					          <div class="modal-body">
 | 
				
			||||||
            {{ A_("Do you really want to delete this transaction?") }}
 | 
					            {{ A_("Do you really want to delete this journal entry?") }}
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="modal-footer">
 | 
					          <div class="modal-footer">
 | 
				
			||||||
            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
					            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
				
			||||||
@@ -92,13 +99,13 @@ First written: 2023/2/26
 | 
				
			|||||||
    {{ obj.date|accounting_format_date }}
 | 
					    {{ obj.date|accounting_format_date }}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {% block transaction_currencies %}{% endblock %}
 | 
					  {% block journal_entry_currencies %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {% if obj.note %}
 | 
					  {% if obj.note %}
 | 
				
			||||||
    <div class="card mb-3">
 | 
					    <div class="card mb-3">
 | 
				
			||||||
      <div class="card-body">
 | 
					      <div class="card-body">
 | 
				
			||||||
        <i class="far fa-comment-dots"></i>
 | 
					        <i class="far fa-comment-dots"></i>
 | 
				
			||||||
        {{ obj.note|accounting_txn_text2html|safe }}
 | 
					        {{ obj.note|accounting_journal_entry_text2html|safe }}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					form-currency.html: The currency sub-form in the journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/3/21
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
				
			||||||
 | 
					  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
				
			||||||
 | 
					  <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
 | 
				
			||||||
 | 
					  <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
 | 
				
			||||||
 | 
					    <div class="d-flex justify-content-between mt-2 mb-3">
 | 
				
			||||||
 | 
					      <div class="form-floating accounting-currency-content">
 | 
				
			||||||
 | 
					        <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
 | 
				
			||||||
 | 
					          {% for currency in accounting_currency_options() %}
 | 
				
			||||||
 | 
					            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
				
			||||||
 | 
					          {% endfor %}
 | 
				
			||||||
 | 
					        </select>
 | 
				
			||||||
 | 
					        <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
 | 
				
			||||||
 | 
					        <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
				
			||||||
 | 
					          <i class="fas fa-minus"></i>
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {% block line_items %}{% endblock %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@@ -0,0 +1,49 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					form-debit-credit.html: The debit or credit line items in the journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/3/21
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					<div class="mb-2">
 | 
				
			||||||
 | 
					  <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
 | 
				
			||||||
 | 
					    <label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label>
 | 
				
			||||||
 | 
					    <ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
 | 
				
			||||||
 | 
					      {% for line_item_form in line_item_forms %}
 | 
				
			||||||
 | 
					        {% with currency_index = currency_index,
 | 
				
			||||||
 | 
					                line_item_index = loop.index,
 | 
				
			||||||
 | 
					                only_one_line_item_form = line_item_forms|length == 1,
 | 
				
			||||||
 | 
					                form = line_item_form.form %}
 | 
				
			||||||
 | 
					          {% include "accounting/journal-entry/include/form-line-item.html" %}
 | 
				
			||||||
 | 
					        {% endwith %}
 | 
				
			||||||
 | 
					      {% endfor %}
 | 
				
			||||||
 | 
					    </ul>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="d-flex justify-content-between mb-2">
 | 
				
			||||||
 | 
					      <div>{{ A_("Total") }}</div>
 | 
				
			||||||
 | 
					      <div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
				
			||||||
 | 
					        <i class="fas fa-plus"></i>
 | 
				
			||||||
 | 
					        {{ A_("New") }}
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@@ -0,0 +1,74 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					form-line-item.html: The line item sub-form in the journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/25
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{# <ul> For SonarQube not to complain about incorrect HTML #}
 | 
				
			||||||
 | 
					<li id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ debit_credit }} {% if form.offsets %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-line-item-index="{{ line_item_index }}" {% if form.is_need_offset %} data-is-need-offset="true" {% endif %}>
 | 
				
			||||||
 | 
					  {% if form.id.data %}
 | 
				
			||||||
 | 
					    <input type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-id" value="{{ form.id.data }}">
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}">
 | 
				
			||||||
 | 
					  <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}">
 | 
				
			||||||
 | 
					  <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}">
 | 
				
			||||||
 | 
					  <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}">
 | 
				
			||||||
 | 
					  <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}">
 | 
				
			||||||
 | 
					  <div class="accounting-line-item-content">
 | 
				
			||||||
 | 
					    <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text" class="small">{{ form.account_text }}</div>
 | 
				
			||||||
 | 
					        <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description-text">{{ form.description.data|accounting_default }}</div>
 | 
				
			||||||
 | 
					        <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not form.original_line_item_id.data %} d-none {% endif %}">
 | 
				
			||||||
 | 
					          {% if form.original_line_item_id.data %}{{ A_("Offset %(item)s", item=form.original_line_item_text|accounting_default) }}{% endif %}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-offsets" class="fst-italic small accounting-offset-line-items {% if not form.is_need_offset %} d-none {% endif %}">
 | 
				
			||||||
 | 
					          {% if form.offsets %}
 | 
				
			||||||
 | 
					            <div class="d-flex justify-content-between {% if not form.offsets %} d-none {% endif %}">
 | 
				
			||||||
 | 
					              <div>{{ A_("Offsets") }}</div>
 | 
				
			||||||
 | 
					              <ul class="ms-2 ps-0">
 | 
				
			||||||
 | 
					                {% for offset in form.offsets %}
 | 
				
			||||||
 | 
					                  <li>{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
 | 
				
			||||||
 | 
					                {% endfor %}
 | 
				
			||||||
 | 
					              </ul>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            {% if form.net_balance == 0 %}
 | 
				
			||||||
 | 
					              <div>{{ A_("Fully offset") }}</div>
 | 
				
			||||||
 | 
					            {% else %}
 | 
				
			||||||
 | 
					              <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					                <div>{{ A_("Net balance") }}</div>
 | 
				
			||||||
 | 
					                <div>{{ form.net_balance|accounting_format_amount }}</div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          {% else %}
 | 
				
			||||||
 | 
					            {{ A_("Unmatched") }}
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount-text" class="badge rounded-pill bg-primary">{{ form.amount.data|accounting_format_amount }}</span></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div>
 | 
				
			||||||
 | 
					    <button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_form or form.offsets %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}">
 | 
				
			||||||
 | 
					      <i class="fas fa-minus"></i>
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</li>
 | 
				
			||||||
 | 
					{# </ul> For SonarQube not to complain about incorrect HTML #}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
form.html: The transfer transaction form
 | 
					form.html: The base journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,28 +23,30 @@ First written: 2023/2/26
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block accounting_scripts %}
 | 
					{% block accounting_scripts %}
 | 
				
			||||||
  <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
 | 
					  <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
 | 
				
			||||||
  <script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
 | 
					  <script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script>
 | 
				
			||||||
 | 
					  <script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
 | 
				
			||||||
  <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
 | 
					  <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
 | 
				
			||||||
  <script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
 | 
					  <script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
 | 
				
			||||||
 | 
					  <script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 d-none d-md-block">
 | 
				
			||||||
  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
					  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}">
 | 
					<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-line-item-template="{{ line_item_template }}">
 | 
				
			||||||
  {{ form.csrf_token }}
 | 
					  {{ form.csrf_token }}
 | 
				
			||||||
  {% if request.args.next %}
 | 
					  {% if request.args.next %}
 | 
				
			||||||
    <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
					    <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="form-floating mb-3">
 | 
					  <div class="form-floating mb-3">
 | 
				
			||||||
    <input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" placeholder=" " required="required">
 | 
					    <input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" max="{{ form.max_date|accounting_default }}" min="{{ form.min_date|accounting_default }}" placeholder=" " required="required">
 | 
				
			||||||
    <label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
 | 
					    <label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
 | 
				
			||||||
    <div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
 | 
					    <div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
@@ -57,7 +59,7 @@ First written: 2023/2/26
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
 | 
					        <button id="accounting-add-currency" class="btn btn-primary" type="button">
 | 
				
			||||||
          <i class="fas fa-plus"></i>
 | 
					          <i class="fas fa-plus"></i>
 | 
				
			||||||
          {{ A_("New") }}
 | 
					          {{ A_("New") }}
 | 
				
			||||||
        </button>
 | 
					        </button>
 | 
				
			||||||
@@ -86,7 +88,8 @@ First written: 2023/2/26
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</form>
 | 
					</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% include "accounting/transaction/include/entry-form-modal.html" %}
 | 
					{% include "accounting/journal-entry/include/journal-entry-line-item-editor-modal.html" %}
 | 
				
			||||||
{% block form_modals %}{% endblock %}
 | 
					{% block form_modals %}{% endblock %}
 | 
				
			||||||
 | 
					{% include "accounting/journal-entry/include/original-line-item-selector-modal.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
@@ -0,0 +1,76 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					journal-entry-line-item-editor-modal: The modal of the journal entry line item editor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/25
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					<form id="accounting-line-item-editor">
 | 
				
			||||||
 | 
					  <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
 | 
				
			||||||
 | 
					    <div class="modal-dialog">
 | 
				
			||||||
 | 
					      <div class="modal-content">
 | 
				
			||||||
 | 
					        <div class="modal-header">
 | 
				
			||||||
 | 
					          <h1 class="modal-title fs-5" id="accounting-line-item-editor-modal-label">{{ A_("Line Item Content") }}</h1>
 | 
				
			||||||
 | 
					          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="modal-body">
 | 
				
			||||||
 | 
					          <div id="accounting-line-item-editor-original-line-item-container" class="d-flex justify-content-between mb-3">
 | 
				
			||||||
 | 
					            <div class="accounting-line-item-editor-original-line-item-content">
 | 
				
			||||||
 | 
					              <div id="accounting-line-item-editor-original-line-item-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
 | 
				
			||||||
 | 
					                <label class="form-label" for="accounting-line-item-editor-original-line-item">{{ A_("Original Line Item") }}</label>
 | 
				
			||||||
 | 
					                <div id="accounting-line-item-editor-original-line-item"></div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div>
 | 
				
			||||||
 | 
					              <button id="accounting-line-item-editor-original-line-item-delete" class="btn btn-danger rounded-circle" type="button">
 | 
				
			||||||
 | 
					                <i class="fas fa-minus"></i>
 | 
				
			||||||
 | 
					              </button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div class="mb-3">
 | 
				
			||||||
 | 
					            <div id="accounting-line-item-editor-description-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
 | 
				
			||||||
 | 
					              <label class="form-label" for="accounting-line-item-editor-description">{{ A_("Description") }}</label>
 | 
				
			||||||
 | 
					              <div id="accounting-line-item-editor-description"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div id="accounting-line-item-editor-description-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div class="mb-3">
 | 
				
			||||||
 | 
					            <div id="accounting-line-item-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
 | 
				
			||||||
 | 
					              <label class="form-label" for="accounting-line-item-editor-account">{{ A_("Account") }}</label>
 | 
				
			||||||
 | 
					              <div id="accounting-line-item-editor-account"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div id="accounting-line-item-editor-account-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div class="form-floating mb-3">
 | 
				
			||||||
 | 
					            <input id="accounting-line-item-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
 | 
				
			||||||
 | 
					            <label for="accounting-line-item-editor-amount">{{ A_("Amount") }}</label>
 | 
				
			||||||
 | 
					            <div id="accounting-line-item-editor-amount-error" class="invalid-feedback"></div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </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-primary">{{ A_("Save") }}</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</form>
 | 
				
			||||||
@@ -0,0 +1,57 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					order-journal-entry.html: The journal entry in the journal entry order page
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/3/21
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					<a class="small w-100 accounting-journal-entry-order-item" href="{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_append_next }}">
 | 
				
			||||||
 | 
					  <div>
 | 
				
			||||||
 | 
					    {{ journal_entry.date|accounting_format_date }}
 | 
				
			||||||
 | 
					    {% if journal_entry.is_cash_disbursement %}
 | 
				
			||||||
 | 
					      {{ A_("Cash Disbursement") }}
 | 
				
			||||||
 | 
					    {% elif journal_entry.is_cash_receipt %}
 | 
				
			||||||
 | 
					      {{ A_("Cash Receipt") }}
 | 
				
			||||||
 | 
					    {% else %}
 | 
				
			||||||
 | 
					      {{ A_("Transfer") }}
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  {% for currency in journal_entry.currencies %}
 | 
				
			||||||
 | 
					    <div class="d-flex justify-content-between accounting-journal-entry-order-item-currency">
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        {% if not journal_entry.is_cash_receipt %}
 | 
				
			||||||
 | 
					          {% for line_item in currency.debit %}
 | 
				
			||||||
 | 
					            <div>{{ line_item.description|accounting_default }}</div>
 | 
				
			||||||
 | 
					          {% endfor %}
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					        {% if not journal_entry.is_cash_disbursement %}
 | 
				
			||||||
 | 
					          {% for line_item in currency.credit %}
 | 
				
			||||||
 | 
					            <div class="accounting-mobile-journal-credit">{{ line_item.description|accounting_default }}</div>
 | 
				
			||||||
 | 
					          {% endfor %}
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        <span class="badge bg-info rounded-pill">
 | 
				
			||||||
 | 
					          {% if currency.code != accounting_default_currency_code() %}
 | 
				
			||||||
 | 
					            {{ currency.code }}
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					          {{ currency.debit_total|accounting_format_amount }}
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  {% endfor %}
 | 
				
			||||||
 | 
					</a>
 | 
				
			||||||
@@ -0,0 +1,56 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					original-line-item-selector-modal.html: The modal of the original line item selector
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/25
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					<div id="accounting-original-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-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-original-line-item-selector-modal-label">{{ A_("Select Original Line Item") }}</h1>
 | 
				
			||||||
 | 
					        <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="modal-body">
 | 
				
			||||||
 | 
					        <div class="input-group mb-2">
 | 
				
			||||||
 | 
					          <input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
 | 
				
			||||||
 | 
					          <label class="input-group-text" for="accounting-original-line-item-selector-query">
 | 
				
			||||||
 | 
					            <i class="fa-solid fa-magnifying-glass"></i>
 | 
				
			||||||
 | 
					            {{ A_("Search") }}
 | 
				
			||||||
 | 
					          </label>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list">
 | 
				
			||||||
 | 
					          {% for line_item in form.original_line_item_options %}
 | 
				
			||||||
 | 
					            <li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
				
			||||||
 | 
					              <div>{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.description|accounting_default }}</div>
 | 
				
			||||||
 | 
					              <div>
 | 
				
			||||||
 | 
					                <span class="badge bg-primary rounded-pill">
 | 
				
			||||||
 | 
					                  <span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span>
 | 
				
			||||||
 | 
					                  / {{ line_item.amount|accounting_format_amount }}
 | 
				
			||||||
 | 
					                </span>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          {% endfor %}
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					        <p id="accounting-original-line-item-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
order.html: The order of the transactions in a same day
 | 
					order.html: The order of the journal entries in a same day
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,14 +23,14 @@ First written: 2023/2/26
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block accounting_scripts %}
 | 
					{% block accounting_scripts %}
 | 
				
			||||||
  <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
 | 
					  <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
 | 
				
			||||||
  <script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script>
 | 
					  <script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %}
 | 
					{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date) }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group mb-3">
 | 
					<div class="mb-3 d-none d-md-block">
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
    {{ A_("Back") }}
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
@@ -38,7 +38,7 @@ First written: 2023/2/26
 | 
				
			|||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% if list|length > 1 and accounting_can_edit() %}
 | 
					{% if list|length > 1 and accounting_can_edit() %}
 | 
				
			||||||
  <form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post">
 | 
					  <form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post">
 | 
				
			||||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
					    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
    {% if request.args.next %}
 | 
					    {% if request.args.next %}
 | 
				
			||||||
      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
					      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
				
			||||||
@@ -47,9 +47,9 @@ First written: 2023/2/26
 | 
				
			|||||||
      {% for item in list %}
 | 
					      {% for item in list %}
 | 
				
			||||||
        <li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
 | 
					        <li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
 | 
				
			||||||
          <input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
 | 
					          <input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
 | 
				
			||||||
          <div>
 | 
					          {% with journal_entry = item %}
 | 
				
			||||||
            {{ item }}
 | 
					            {% include "accounting/journal-entry/include/order-journal-entry.html" %}
 | 
				
			||||||
          </div>
 | 
					          {% endwith %}
 | 
				
			||||||
          <i class="fa-solid fa-bars"></i>
 | 
					          <i class="fa-solid fa-bars"></i>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
      {% endfor %}
 | 
					      {% endfor %}
 | 
				
			||||||
@@ -72,7 +72,9 @@ First written: 2023/2/26
 | 
				
			|||||||
  <ul class="list-group mb-3">
 | 
					  <ul class="list-group mb-3">
 | 
				
			||||||
    {% for item in list %}
 | 
					    {% for item in list %}
 | 
				
			||||||
      <li class="list-group-item">
 | 
					      <li class="list-group-item">
 | 
				
			||||||
        {{ item }}
 | 
					        {% with journal_entry = item %}
 | 
				
			||||||
 | 
					          {% include "accounting/journal-entry/include/order-journal-entry.html" %}
 | 
				
			||||||
 | 
					        {% endwith %}
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
    {% endfor %}
 | 
					    {% endfor %}
 | 
				
			||||||
  </ul>
 | 
					  </ul>
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
create.html: The transfer transaction creation form
 | 
					create.html: The cash receipt journal entry creation form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,10 +19,10 @@ create.html: The transfer transaction creation form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/transfer/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/receipt/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %}
 | 
					{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
 | 
					{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
 | 
					{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
detail.html: The account detail
 | 
					detail.html: The cash receipt journal entry detail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,36 +19,26 @@ detail.html: The account detail
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/26
 | 
					First written: 2023/2/26
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/include/detail.html" %}
 | 
					{% extends "accounting/journal-entry/include/detail.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block to_transfer %}
 | 
					{% block as_trasfer %}
 | 
				
			||||||
  <a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
 | 
				
			||||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
					    <i class="fa-solid fa-bars-staggered"></i>
 | 
				
			||||||
    {{ A_("To Transfer") }}
 | 
					    <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block transaction_currencies %}
 | 
					{% block journal_entry_currencies %}
 | 
				
			||||||
  {% for currency in obj.currencies %}
 | 
					  {% for currency in obj.currencies %}
 | 
				
			||||||
    <div class="mb-3">
 | 
					    <div class="mb-3">
 | 
				
			||||||
      <div class="mb-2 fw-bolder">{{ currency.name }}</div>
 | 
					      <div class="mb-2 fw-bolder">{{ currency.name }}</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
 | 
					      <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
 | 
				
			||||||
        <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
 | 
					        <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
 | 
				
			||||||
        {% for entry in currency.debit %}
 | 
					        {% with line_items = currency.credit %}
 | 
				
			||||||
          <li class="list-group-item accounting-transaction-entry">
 | 
					          {% include "accounting/journal-entry/include/detail-line-items.html" %}
 | 
				
			||||||
            <div class="d-flex justify-content-between">
 | 
					        {% endwith %}
 | 
				
			||||||
              <div>
 | 
					        <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
 | 
				
			||||||
                <div class="small">{{ entry.account }}</div>
 | 
					 | 
				
			||||||
                {% if entry.summary is not none %}
 | 
					 | 
				
			||||||
                  <div>{{ entry.summary }}</div>
 | 
					 | 
				
			||||||
                {% endif %}
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
              <div>{{ entry.amount|accounting_format_amount }}</div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </li>
 | 
					 | 
				
			||||||
        {% endfor %}
 | 
					 | 
				
			||||||
        <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
 | 
					 | 
				
			||||||
          <div class="d-flex justify-content-between">
 | 
					          <div class="d-flex justify-content-between">
 | 
				
			||||||
            <div>{{ A_("Total") }}</div>
 | 
					            <div>{{ A_("Total") }}</div>
 | 
				
			||||||
            <div>{{ currency.debit_total|accounting_format_amount }}</div>
 | 
					            <div>{{ currency.debit_total|accounting_format_amount }}</div>
 | 
				
			||||||
@@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					edit.html: The cash receipt journal entry edit form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/25
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/journal-entry/receipt/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
edit.html: The cash expense transaction edit form
 | 
					form-currency.html: The currency sub-form in the cash receipt journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,10 +19,15 @@ edit.html: The cash expense transaction edit form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/expense/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/include/form-currency.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
 | 
					{% block line_items %}
 | 
				
			||||||
 | 
					  {% with currency_index = currency_index,
 | 
				
			||||||
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
 | 
					          debit_credit = "credit",
 | 
				
			||||||
 | 
					          line_item_forms = credit_forms,
 | 
				
			||||||
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
 | 
					          header = A_("Content"),
 | 
				
			||||||
 | 
					          debit_credit_total = credit_total,
 | 
				
			||||||
 | 
					          debit_credit_errors = credit_errors %}
 | 
				
			||||||
 | 
					    {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
				
			||||||
 | 
					  {% endwith %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
form.html: The cash income transaction form
 | 
					form.html: The cash receipt journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,7 +19,7 @@ form.html: The cash income transaction form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block currency_sub_forms %}
 | 
					{% block currency_sub_forms %}
 | 
				
			||||||
  {% if form.currencies %}
 | 
					  {% if form.currencies %}
 | 
				
			||||||
@@ -29,10 +29,11 @@ First written: 2023/2/25
 | 
				
			|||||||
              currency_errors = currency_form.whole_form.errors,
 | 
					              currency_errors = currency_form.whole_form.errors,
 | 
				
			||||||
              currency_code_data = currency_form.code.data,
 | 
					              currency_code_data = currency_form.code.data,
 | 
				
			||||||
              currency_code_errors = currency_form.code.errors,
 | 
					              currency_code_errors = currency_form.code.errors,
 | 
				
			||||||
 | 
					              currency_code_is_locked = currency_form.is_code_locked,
 | 
				
			||||||
              credit_forms = currency_form.credit,
 | 
					              credit_forms = currency_form.credit,
 | 
				
			||||||
              credit_errors = currency_form.credit_errors,
 | 
					              credit_errors = currency_form.credit_errors,
 | 
				
			||||||
              credit_total = currency_form.form.credit_total|accounting_format_amount %}
 | 
					              credit_total = currency_form.credit_total|accounting_format_amount %}
 | 
				
			||||||
        {% include "accounting/transaction/income/include/form-currency-item.html" %}
 | 
					        {% include "accounting/journal-entry/receipt/include/form-currency.html" %}
 | 
				
			||||||
      {% endwith %}
 | 
					      {% endwith %}
 | 
				
			||||||
    {% endfor %}
 | 
					    {% endfor %}
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
@@ -40,17 +41,17 @@ First written: 2023/2/25
 | 
				
			|||||||
            only_one_currency_form = True,
 | 
					            only_one_currency_form = True,
 | 
				
			||||||
            currency_code_data = accounting_default_currency_code(),
 | 
					            currency_code_data = accounting_default_currency_code(),
 | 
				
			||||||
            credit_total = "-" %}
 | 
					            credit_total = "-" %}
 | 
				
			||||||
      {% include "accounting/transaction/income/include/form-currency-item.html" %}
 | 
					      {% include "accounting/journal-entry/receipt/include/form-currency.html" %}
 | 
				
			||||||
    {% endwith %}
 | 
					    {% endwith %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block form_modals %}
 | 
					{% block form_modals %}
 | 
				
			||||||
  {% with summary_editor = form.summary_editor.credit %}
 | 
					  {% with description_editor = form.description_editor.credit %}
 | 
				
			||||||
    {% include "accounting/transaction/include/summary-editor-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/description-editor-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
  {% with entry_type = "credit",
 | 
					  {% with debit_credit = "credit",
 | 
				
			||||||
          account_options = form.credit_account_options %}
 | 
					          account_options = form.credit_account_options %}
 | 
				
			||||||
    {% include "accounting/transaction/include/account-selector-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/account-selector-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
create.html: The cash income transaction creation form
 | 
					create.html: The transfer journal entry creation form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,10 +19,10 @@ create.html: The cash income transaction creation form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/income/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/transfer/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %}
 | 
					{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
 | 
					{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
 | 
					{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
 | 
				
			||||||
@@ -0,0 +1,64 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					detail.html: The transfer journal entry detail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/26
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/journal-entry/include/detail.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block journal_entry_currencies %}
 | 
				
			||||||
 | 
					  {% for currency in obj.currencies %}
 | 
				
			||||||
 | 
					    <div class="mb-3">
 | 
				
			||||||
 | 
					      <div class="mb-2 fw-bolder">{{ currency.name }}</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="row">
 | 
				
			||||||
 | 
					        {# The debit line items #}
 | 
				
			||||||
 | 
					        <div class="col-sm-6 mb-2">
 | 
				
			||||||
 | 
					          <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
 | 
				
			||||||
 | 
					            <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Debit") }}</li>
 | 
				
			||||||
 | 
					            {% with line_items = currency.debit %}
 | 
				
			||||||
 | 
					              {% include "accounting/journal-entry/include/detail-line-items.html" %}
 | 
				
			||||||
 | 
					            {% endwith %}
 | 
				
			||||||
 | 
					            <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
 | 
				
			||||||
 | 
					              <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					                <div>{{ A_("Total") }}</div>
 | 
				
			||||||
 | 
					                <div>{{ currency.debit_total|accounting_format_amount }}</div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {# The credit line items #}
 | 
				
			||||||
 | 
					        <div class="col-sm-6 mb-2">
 | 
				
			||||||
 | 
					          <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
 | 
				
			||||||
 | 
					            <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Credit") }}</li>
 | 
				
			||||||
 | 
					            {% with line_items = currency.credit %}
 | 
				
			||||||
 | 
					              {% include "accounting/journal-entry/include/detail-line-items.html" %}
 | 
				
			||||||
 | 
					            {% endwith %}
 | 
				
			||||||
 | 
					            <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
 | 
				
			||||||
 | 
					              <div class="d-flex justify-content-between">
 | 
				
			||||||
 | 
					                <div>{{ A_("Total") }}</div>
 | 
				
			||||||
 | 
					                <div>{{ currency.debit_total|accounting_format_amount }}</div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  {% endfor %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
edit.html: The transfer transaction edit form
 | 
					edit.html: The transfer journal entry edit form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,10 +19,10 @@ edit.html: The transfer transaction edit form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/transfer/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/transfer/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
 | 
					{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
 | 
					{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
 | 
					{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}
 | 
				
			||||||
@@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					form-currency.html: The currency sub-form in the transfer journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/25
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/journal-entry/include/form-currency.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block line_items %}
 | 
				
			||||||
 | 
					<div class="row">
 | 
				
			||||||
 | 
					  <div class="col-sm-6">
 | 
				
			||||||
 | 
					    {% with currency_index = currency_index,
 | 
				
			||||||
 | 
					            debit_credit = "debit",
 | 
				
			||||||
 | 
					            line_item_forms = debit_forms,
 | 
				
			||||||
 | 
					            header = A_("Debit"),
 | 
				
			||||||
 | 
					            debit_credit_total = debit_total,
 | 
				
			||||||
 | 
					            debit_credit_errors = debit_errors %}
 | 
				
			||||||
 | 
					      {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
				
			||||||
 | 
					    {% endwith %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="col-sm-6">
 | 
				
			||||||
 | 
					    {% with currency_index = currency_index,
 | 
				
			||||||
 | 
					            debit_credit = "credit",
 | 
				
			||||||
 | 
					            line_item_forms = credit_forms,
 | 
				
			||||||
 | 
					            header = A_("Credit"),
 | 
				
			||||||
 | 
					            debit_credit_total = credit_total,
 | 
				
			||||||
 | 
					            debit_credit_errors = credit_errors %}
 | 
				
			||||||
 | 
					      {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
				
			||||||
 | 
					    {% endwith %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
form.html: The transfer transaction form
 | 
					form.html: The transfer journal entry form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,7 +19,7 @@ form.html: The transfer transaction form
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/2/25
 | 
					First written: 2023/2/25
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/transaction/include/form.html" %}
 | 
					{% extends "accounting/journal-entry/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block currency_sub_forms %}
 | 
					{% block currency_sub_forms %}
 | 
				
			||||||
  {% if form.currencies %}
 | 
					  {% if form.currencies %}
 | 
				
			||||||
@@ -29,13 +29,14 @@ First written: 2023/2/25
 | 
				
			|||||||
              currency_errors = currency_form.whole_form.errors,
 | 
					              currency_errors = currency_form.whole_form.errors,
 | 
				
			||||||
              currency_code_data = currency_form.code.data,
 | 
					              currency_code_data = currency_form.code.data,
 | 
				
			||||||
              currency_code_errors = currency_form.code.errors,
 | 
					              currency_code_errors = currency_form.code.errors,
 | 
				
			||||||
 | 
					              currency_code_is_locked = currency_form.is_code_locked,
 | 
				
			||||||
              debit_forms = currency_form.debit,
 | 
					              debit_forms = currency_form.debit,
 | 
				
			||||||
              debit_errors = currency_form.debit_errors,
 | 
					              debit_errors = currency_form.debit_errors,
 | 
				
			||||||
              debit_total = currency_form.form.debit_total|accounting_format_amount,
 | 
					              debit_total = currency_form.debit_total|accounting_format_amount,
 | 
				
			||||||
              credit_forms = currency_form.credit,
 | 
					              credit_forms = currency_form.credit,
 | 
				
			||||||
              credit_errors = currency_form.credit_errors,
 | 
					              credit_errors = currency_form.credit_errors,
 | 
				
			||||||
              credit_total = currency_form.form.credit_total|accounting_format_amount %}
 | 
					              credit_total = currency_form.credit_total|accounting_format_amount %}
 | 
				
			||||||
        {% include "accounting/transaction/transfer/include/form-currency-item.html" %}
 | 
					        {% include "accounting/journal-entry/transfer/include/form-currency.html" %}
 | 
				
			||||||
      {% endwith %}
 | 
					      {% endwith %}
 | 
				
			||||||
    {% endfor %}
 | 
					    {% endfor %}
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
@@ -44,24 +45,24 @@ First written: 2023/2/25
 | 
				
			|||||||
            currency_code_data = accounting_default_currency_code(),
 | 
					            currency_code_data = accounting_default_currency_code(),
 | 
				
			||||||
            debit_total = "-",
 | 
					            debit_total = "-",
 | 
				
			||||||
            credit_total = "-" %}
 | 
					            credit_total = "-" %}
 | 
				
			||||||
      {% include "accounting/transaction/transfer/include/form-currency-item.html" %}
 | 
					      {% include "accounting/journal-entry/transfer/include/form-currency.html" %}
 | 
				
			||||||
    {% endwith %}
 | 
					    {% endwith %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block form_modals %}
 | 
					{% block form_modals %}
 | 
				
			||||||
  {% with summary_editor = form.summary_editor.debit %}
 | 
					  {% with description_editor = form.description_editor.debit %}
 | 
				
			||||||
    {% include "accounting/transaction/include/summary-editor-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/description-editor-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
  {% with summary_editor = form.summary_editor.credit %}
 | 
					  {% with description_editor = form.description_editor.credit %}
 | 
				
			||||||
    {% include "accounting/transaction/include/summary-editor-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/description-editor-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
  {% with entry_type = "debit",
 | 
					  {% with debit_credit = "debit",
 | 
				
			||||||
          account_options = form.debit_account_options %}
 | 
					          account_options = form.debit_account_options %}
 | 
				
			||||||
    {% include "accounting/transaction/include/account-selector-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/account-selector-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
  {% with entry_type = "credit",
 | 
					  {% with debit_credit = "credit",
 | 
				
			||||||
          account_options = form.credit_account_options %}
 | 
					          account_options = form.credit_account_options %}
 | 
				
			||||||
    {% include "accounting/transaction/include/account-selector-modal.html" %}
 | 
					    {% include "accounting/journal-entry/include/account-selector-modal.html" %}
 | 
				
			||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
@@ -37,7 +37,7 @@ First written: 2023/3/7
 | 
				
			|||||||
  {% endwith %}
 | 
					  {% endwith %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
					{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% include "accounting/report/include/period-chooser.html" %}
 | 
					{% include "accounting/report/include/period-chooser.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{#
 | 
					{#
 | 
				
			||||||
The Mia! Accounting Flask Project
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
add-txn-material-fab.html: The material floating action buttons to add a new transaction
 | 
					add-journal-entry-material-fab.html: The material floating action buttons to add a new journal entry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 Copyright (c) 2023 imacat.
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,13 +22,13 @@ First written: 2023/2/25
 | 
				
			|||||||
{% if accounting_can_edit() %}
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
  <div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
 | 
					  <div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
 | 
				
			||||||
    <div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
 | 
					    <div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
 | 
				
			||||||
      <a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
 | 
					      <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
 | 
				
			||||||
        {{ A_("Cash expense") }}
 | 
					        {{ A_("Cash Disbursement") }}
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
      <a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
 | 
					      <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
 | 
				
			||||||
        {{ A_("Cash income") }}
 | 
					        {{ A_("Cash Receipt") }}
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
      <a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
 | 
					      <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
 | 
				
			||||||
        {{ A_("Transfer") }}
 | 
					        {{ A_("Transfer") }}
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -19,9 +19,9 @@ income-expenses-row-desktop.html: The row in the income and expenses log for the
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/3/8
 | 
					First written: 2023/3/8
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
<div>{{ entry.date|accounting_format_date }}</div>
 | 
					<div>{{ line_item.date|accounting_format_date }}</div>
 | 
				
			||||||
<div>{{ entry.account.title|title }}</div>
 | 
					<div>{{ line_item.account.title|title }}</div>
 | 
				
			||||||
<div>{{ entry.summary|accounting_default }}</div>
 | 
					<div>{{ line_item.description|accounting_default }}</div>
 | 
				
			||||||
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
 | 
					<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
 | 
				
			||||||
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
 | 
					<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>
 | 
				
			||||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
 | 
					<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			|||||||
First written: 2023/3/5
 | 
					First written: 2023/3/5
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
  {% if entry.date or entry.account %}
 | 
					  {% if line_item.date or line_item.account %}
 | 
				
			||||||
    <div class="text-muted small">
 | 
					    <div class="text-muted small">
 | 
				
			||||||
      {% if entry.date %}
 | 
					      {% if line_item.date %}
 | 
				
			||||||
        {{ entry.date|accounting_format_date }}
 | 
					        {{ line_item.date|accounting_format_date }}
 | 
				
			||||||
      {% endif %}
 | 
					      {% endif %}
 | 
				
			||||||
      {% if entry.account %}
 | 
					      {% if line_item.account %}
 | 
				
			||||||
        {{ entry.account.title|title }}
 | 
					        {{ line_item.account.title|title }}
 | 
				
			||||||
      {% endif %}
 | 
					      {% endif %}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  {% if entry.summary %}
 | 
					  {% if line_item.description %}
 | 
				
			||||||
    <div>{{ entry.summary }}</div>
 | 
					    <div>{{ line_item.description }}</div>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="text-nowrap">
 | 
					<div class="text-nowrap">
 | 
				
			||||||
  {% if entry.income %}
 | 
					  {% if line_item.income %}
 | 
				
			||||||
    <span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
 | 
					    <span class="badge rounded-pill bg-success">+{{ line_item.income|accounting_format_amount }}</span>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  {% if entry.expense %}
 | 
					  {% if line_item.expense %}
 | 
				
			||||||
    <span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
 | 
					    <span class="badge rounded-pill bg-warning">-{{ line_item.expense|accounting_format_amount }}</span>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  {% if entry.balance < 0 %}
 | 
					  {% if line_item.balance < 0 %}
 | 
				
			||||||
    <span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
 | 
					    <span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
    <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
 | 
					    <span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,8 +19,10 @@ ledger-row-desktop.html: The row in the ledger for the desktop computers
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/3/8
 | 
					First written: 2023/3/8
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
<div>{{ entry.date|accounting_format_date }}</div>
 | 
					<div>{{ line_item.date|accounting_format_date }}</div>
 | 
				
			||||||
<div>{{ entry.summary|accounting_default }}</div>
 | 
					<div>{{ line_item.description|accounting_default }}</div>
 | 
				
			||||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
 | 
					<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
 | 
				
			||||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
 | 
					<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
 | 
				
			||||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
 | 
					{% if report.account.is_real %}
 | 
				
			||||||
 | 
					  <div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,22 +20,24 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			|||||||
First written: 2023/3/5
 | 
					First written: 2023/3/5
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
  {% if entry.date %}
 | 
					  {% if line_item.date %}
 | 
				
			||||||
    <div class="text-muted small">
 | 
					    <div class="text-muted small">
 | 
				
			||||||
      {{ entry.date|accounting_format_date }}
 | 
					      {{ line_item.date|accounting_format_date }}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  {% if entry.summary %}
 | 
					  {% if line_item.description %}
 | 
				
			||||||
    <div>{{ entry.summary }}</div>
 | 
					    <div>{{ line_item.description }}</div>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
  {% if entry.debit %}
 | 
					  {% if line_item.debit %}
 | 
				
			||||||
    <span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
 | 
					    <span class="badge rounded-pill bg-success">+{{ line_item.debit|accounting_format_amount }}</span>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  {% if entry.credit %}
 | 
					  {% if line_item.credit %}
 | 
				
			||||||
    <span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
 | 
					    <span class="badge rounded-pill bg-warning">-{{ line_item.credit|accounting_format_amount }}</span>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  {% if report.account.is_real %}
 | 
				
			||||||
 | 
					    <span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
 | 
					 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user