Compare commits
	
		
			116 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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,6 +23,7 @@ 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/templates/* | include tests/test_site/templates/* | ||||||
| include tests/test_site/translations/* | include tests/test_site/translations/* | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								docs/source/accounting.transaction.forms.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								docs/source/accounting.transaction.forms.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | accounting.transaction.forms package | ||||||
|  | ==================================== | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.transaction.forms.currency module | ||||||
|  | -------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.forms.currency | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.forms.journal\_entry module | ||||||
|  | -------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.forms.journal_entry | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.forms.reorder module | ||||||
|  | ------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.forms.reorder | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.forms.transaction module | ||||||
|  | ----------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.forms.transaction | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.forms | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
| @@ -1,6 +1,15 @@ | |||||||
| accounting.transaction package | accounting.transaction package | ||||||
| ============================== | ============================== | ||||||
|  |  | ||||||
|  | Subpackages | ||||||
|  | ----------- | ||||||
|  |  | ||||||
|  | .. toctree:: | ||||||
|  |    :maxdepth: 4 | ||||||
|  |  | ||||||
|  |    accounting.transaction.forms | ||||||
|  |    accounting.transaction.utils | ||||||
|  |  | ||||||
| Submodules | Submodules | ||||||
| ---------- | ---------- | ||||||
|  |  | ||||||
| @@ -12,30 +21,6 @@ accounting.transaction.converters module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :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 | accounting.transaction.template\_filters module | ||||||
| ----------------------------------------------- | ----------------------------------------------- | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										53
									
								
								docs/source/accounting.transaction.utils.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.transaction.utils.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | accounting.transaction.utils package | ||||||
|  | ==================================== | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.transaction.utils.account\_option module | ||||||
|  | --------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.utils.account_option | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.utils.offset\_alias module | ||||||
|  | ------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.utils.offset_alias | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.utils.operators module | ||||||
|  | --------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.utils.operators | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.utils.original\_entries module | ||||||
|  | ----------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.utils.original_entries | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.utils.summary\_editor module | ||||||
|  | --------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.utils.summary_editor | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.utils | ||||||
|  |    :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 | ||||||
| ------------------------------------- | ------------------------------------- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|  |  | ||||||
| [metadata] | [metadata] | ||||||
| name = mia-accounting-flask | name = mia-accounting-flask | ||||||
| version = 0.5.0 | version = 0.6.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) | ||||||
|   | |||||||
| @@ -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,36 @@ 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 entries in the account need offset. | ||||||
|  |  | ||||||
|  |     :param base_code: The code of the base account. | ||||||
|  |     :return: True if entries 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 +134,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,7 +80,8 @@ 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( | ||||||
|  |         validators=[NoOffsetNominalAccount()]) | ||||||
|     """Whether the the entries of this account need offset.""" |     """Whether the the entries of this account need offset.""" | ||||||
|  |  | ||||||
|     def populate_obj(self, obj: Account) -> None: |     def populate_obj(self, obj: Account) -> None: | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -48,7 +48,7 @@ def get_account_query() -> list[Account]: | |||||||
|                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("Need 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))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -159,7 +160,7 @@ def delete_account(account: Account) -> redirect: | |||||||
|     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 +187,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())) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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.")), | ||||||
|   | |||||||
| @@ -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))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -161,7 +162,7 @@ def delete_currency(currency: Currency) -> redirect: | |||||||
|     """ |     """ | ||||||
|     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 +183,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) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ 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 | ||||||
| @@ -113,7 +114,7 @@ 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 entries 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()) | ||||||
| @@ -197,6 +198,52 @@ class Account(db.Model): | |||||||
|                 return |                 return | ||||||
|         self.l10n.append(AccountL10n(locale=current_locale, title=value)) |         self.l10n.append(AccountL10n(locale=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 | ||||||
|  |  | ||||||
|  |     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: | ||||||
|         """Finds an account by its code. |         """Finds an account by its code. | ||||||
| @@ -251,14 +298,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 +314,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.""" | ||||||
| @@ -568,6 +585,21 @@ class Transaction(db.Model): | |||||||
|                 return False |                 return False | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def can_delete(self) -> bool: | ||||||
|  |         """Returns whether the transaction can be deleted. | ||||||
|  |  | ||||||
|  |         :return: True if the transaction can be deleted, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "__can_delete"): | ||||||
|  |             def has_offset() -> bool: | ||||||
|  |                 for entry in self.entries: | ||||||
|  |                     if len(entry.offsets) > 0: | ||||||
|  |                         return True | ||||||
|  |                 return False | ||||||
|  |             setattr(self, "__can_delete", not has_offset()) | ||||||
|  |         return getattr(self, "__can_delete") | ||||||
|  |  | ||||||
|     def delete(self) -> None: |     def delete(self) -> None: | ||||||
|         """Deletes the transaction. |         """Deletes the transaction. | ||||||
|  |  | ||||||
| @@ -597,14 +629,14 @@ class JournalEntry(db.Model): | |||||||
|     """True for a debit entry, or False for a credit entry.""" |     """True for a debit entry, or False for a credit entry.""" | ||||||
|     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 entry number under the transaction and debit or credit.""" | ||||||
|     offset_original_id = db.Column(db.Integer, |     original_entry_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 entry.""" | ||||||
|     offset_original = db.relationship("JournalEntry", back_populates="offsets", |     original_entry = db.relationship("JournalEntry", back_populates="offsets", | ||||||
|                                       remote_side=id, passive_deletes=True) |                                      remote_side=id, passive_deletes=True) | ||||||
|     """The original entry to offset.""" |     """The original entry.""" | ||||||
|     offsets = db.relationship("JournalEntry", back_populates="offset_original") |     offsets = db.relationship("JournalEntry", back_populates="original_entry") | ||||||
|     """The offset entries.""" |     """The offset entries.""" | ||||||
|     currency_code = db.Column(db.String, |     currency_code = db.Column(db.String, | ||||||
|                               db.ForeignKey(Currency.code, onupdate="CASCADE"), |                               db.ForeignKey(Currency.code, onupdate="CASCADE"), | ||||||
| @@ -624,6 +656,21 @@ class JournalEntry(db.Model): | |||||||
|     amount = db.Column(db.Numeric(14, 2), nullable=False) |     amount = db.Column(db.Numeric(14, 2), nullable=False) | ||||||
|     """The amount.""" |     """The amount.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the journal entry. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the journal entry. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "__str"): | ||||||
|  |             from accounting.template_filters import format_date, format_amount | ||||||
|  |             setattr(self, "__str", | ||||||
|  |                     gettext("%(date)s %(summary)s %(amount)s", | ||||||
|  |                             date=format_date(self.transaction.date), | ||||||
|  |                             summary="" if self.summary is None | ||||||
|  |                             else self.summary, | ||||||
|  |                             amount=format_amount(self.amount))) | ||||||
|  |         return getattr(self, "__str") | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def eid(self) -> int | None: |     def eid(self) -> int | None: | ||||||
|         """Returns the journal entry ID.  This is the alternative name of the |         """Returns the journal entry ID.  This is the alternative name of the | ||||||
| @@ -649,6 +696,20 @@ class JournalEntry(db.Model): | |||||||
|         """ |         """ | ||||||
|         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 entry needs offset. | ||||||
|  |  | ||||||
|  |         :return: True if the entry 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. | ||||||
| @@ -656,3 +717,45 @@ class JournalEntry(db.Model): | |||||||
|         :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 entry. | ||||||
|         """ |         """ | ||||||
|         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:] | ||||||
|  |  | ||||||
|  |         txn_day: date = self.transaction.date | ||||||
|  |         summary: str = "" if self.summary is None else self.summary | ||||||
|  |         return ([summary], | ||||||
|  |                 [str(txn_day.year), | ||||||
|  |                  "{}/{}".format(txn_day.year, txn_day.month), | ||||||
|  |                  "{}/{}".format(txn_day.month, txn_day.day), | ||||||
|  |                  "{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day), | ||||||
|  |                  format_amount(self.amount), | ||||||
|  |                  format_amount(self.net_balance)]) | ||||||
|   | |||||||
| @@ -188,10 +188,10 @@ 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. | ||||||
| @@ -213,7 +213,7 @@ 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), |             (JournalEntry.is_debit, JournalEntry.amount), | ||||||
|             else_=-JournalEntry.amount)) |             else_=-JournalEntry.amount)) | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ 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 | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -120,7 +121,7 @@ class EntryCollector: | |||||||
|             else_=-JournalEntry.amount)) |             else_=-JournalEntry.amount)) | ||||||
|         select: sa.Select = sa.Select(balance_func)\ |         select: sa.Select = sa.Select(balance_func)\ | ||||||
|             .join(Transaction).join(Account)\ |             .join(Transaction).join(Account)\ | ||||||
|             .filter(JournalEntry.currency_code == self.__currency.code, |             .filter(be(JournalEntry.currency_code == self.__currency.code), | ||||||
|                     self.__account_condition, |                     self.__account_condition, | ||||||
|                     Transaction.date < self.__period.start) |                     Transaction.date < self.__period.start) | ||||||
|         balance: int | None = db.session.scalar(select) |         balance: int | None = db.session.scalar(select) | ||||||
| @@ -159,6 +160,7 @@ class EntryCollector: | |||||||
|                         JournalEntry.currency_code == self.__currency.code, |                         JournalEntry.currency_code == self.__currency.code, | ||||||
|                         sa.not_(self.__account_condition)) |                         sa.not_(self.__account_condition)) | ||||||
|                 .order_by(Transaction.date, |                 .order_by(Transaction.date, | ||||||
|  |                           Transaction.no, | ||||||
|                           JournalEntry.is_debit, |                           JournalEntry.is_debit, | ||||||
|                           JournalEntry.no) |                           JournalEntry.no) | ||||||
|                 .options(selectinload(JournalEntry.account), |                 .options(selectinload(JournalEntry.account), | ||||||
| @@ -342,7 +344,7 @@ class PageParams(BasePageParams): | |||||||
|                           self.account.id == 0)] |                           self.account.id == 0)] | ||||||
|         in_use: sa.Select = sa.Select(JournalEntry.account_id)\ |         in_use: sa.Select = sa.Select(JournalEntry.account_id)\ | ||||||
|             .join(Account)\ |             .join(Account)\ | ||||||
|             .filter(JournalEntry.currency_code == self.currency.code, |             .filter(be(JournalEntry.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"), | ||||||
| @@ -433,7 +435,7 @@ class IncomeExpenses(BaseReport): | |||||||
|         if self.__total is not None: |         if self.__total is not None: | ||||||
|             all_entries.append(self.__total) |             all_entries.append(self.__total) | ||||||
|         pagination: Pagination[ReportEntry] \ |         pagination: Pagination[ReportEntry] \ | ||||||
|             = Pagination[ReportEntry](all_entries) |             = Pagination[ReportEntry](all_entries, is_reversed=True) | ||||||
|         page_entries: list[ReportEntry] = pagination.list |         page_entries: list[ReportEntry] = pagination.list | ||||||
|         has_data: bool = len(page_entries) > 0 |         has_data: bool = len(page_entries) > 0 | ||||||
|         brought_forward: ReportEntry | None = None |         brought_forward: ReportEntry | None = None | ||||||
|   | |||||||
| @@ -188,6 +188,7 @@ class Journal(BaseReport): | |||||||
|         return JournalEntry.query.join(Transaction)\ |         return JournalEntry.query.join(Transaction)\ | ||||||
|             .filter(*conditions)\ |             .filter(*conditions)\ | ||||||
|             .order_by(Transaction.date, |             .order_by(Transaction.date, | ||||||
|  |                       Transaction.no, | ||||||
|                       JournalEntry.is_debit.desc(), |                       JournalEntry.is_debit.desc(), | ||||||
|                       JournalEntry.no)\ |                       JournalEntry.no)\ | ||||||
|             .options(selectinload(JournalEntry.account), |             .options(selectinload(JournalEntry.account), | ||||||
| @@ -208,7 +209,7 @@ class Journal(BaseReport): | |||||||
|         :return: The report as HTML. |         :return: The report as HTML. | ||||||
|         """ |         """ | ||||||
|         pagination: Pagination[JournalEntry] \ |         pagination: Pagination[JournalEntry] \ | ||||||
|             = Pagination[JournalEntry](self.__entries) |             = Pagination[JournalEntry](self.__entries, is_reversed=True) | ||||||
|         params: PageParams = PageParams(period=self.__period, |         params: PageParams = PageParams(period=self.__period, | ||||||
|                                         pagination=pagination, |                                         pagination=pagination, | ||||||
|                                         entries=pagination.list) |                                         entries=pagination.list) | ||||||
|   | |||||||
| @@ -36,6 +36,7 @@ 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 | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -110,14 +111,14 @@ class EntryCollector: | |||||||
|         """ |         """ | ||||||
|         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), |             (JournalEntry.is_debit, JournalEntry.amount), | ||||||
|             else_=-JournalEntry.amount)) |             else_=-JournalEntry.amount)) | ||||||
|         select: sa.Select = sa.Select(balance_func).join(Transaction)\ |         select: sa.Select = sa.Select(balance_func).join(Transaction)\ | ||||||
|             .filter(JournalEntry.currency_code == self.__currency.code, |             .filter(be(JournalEntry.currency_code == self.__currency.code), | ||||||
|                     JournalEntry.account_id == self.__account.id, |                     be(JournalEntry.account_id == self.__account.id), | ||||||
|                     Transaction.date < self.__period.start) |                     Transaction.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: | ||||||
| @@ -148,6 +149,7 @@ class EntryCollector: | |||||||
|         return [ReportEntry(x) for x in JournalEntry.query.join(Transaction) |         return [ReportEntry(x) for x in JournalEntry.query.join(Transaction) | ||||||
|                 .filter(*conditions) |                 .filter(*conditions) | ||||||
|                 .order_by(Transaction.date, |                 .order_by(Transaction.date, | ||||||
|  |                           Transaction.no, | ||||||
|                           JournalEntry.is_debit.desc(), |                           JournalEntry.is_debit.desc(), | ||||||
|                           JournalEntry.no) |                           JournalEntry.no) | ||||||
|                 .options(selectinload(JournalEntry.transaction)).all()] |                 .options(selectinload(JournalEntry.transaction)).all()] | ||||||
| @@ -176,6 +178,8 @@ class EntryCollector: | |||||||
|  |  | ||||||
|         :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 entry in self.entries: | ||||||
| @@ -303,7 +307,7 @@ 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(JournalEntry.account_id)\ | ||||||
|             .filter(JournalEntry.currency_code == self.currency.code)\ |             .filter(be(JournalEntry.currency_code == self.currency.code))\ | ||||||
|             .group_by(JournalEntry.account_id) |             .group_by(JournalEntry.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) | ||||||
| @@ -382,7 +386,7 @@ class Ledger(BaseReport): | |||||||
|         if self.__total is not None: |         if self.__total is not None: | ||||||
|             all_entries.append(self.__total) |             all_entries.append(self.__total) | ||||||
|         pagination: Pagination[ReportEntry] \ |         pagination: Pagination[ReportEntry] \ | ||||||
|             = Pagination[ReportEntry](all_entries) |             = Pagination[ReportEntry](all_entries, is_reversed=True) | ||||||
|         page_entries: list[ReportEntry] = pagination.list |         page_entries: list[ReportEntry] = pagination.list | ||||||
|         has_data: bool = len(page_entries) > 0 |         has_data: bool = len(page_entries) > 0 | ||||||
|         brought_forward: ReportEntry | None = None |         brought_forward: ReportEntry | None = None | ||||||
|   | |||||||
| @@ -32,6 +32,7 @@ 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 | ||||||
| @@ -68,7 +69,11 @@ class EntryCollector: | |||||||
|             except ArithmeticError: |             except ArithmeticError: | ||||||
|                 pass |                 pass | ||||||
|             conditions.append(sa.or_(*sub_conditions)) |             conditions.append(sa.or_(*sub_conditions)) | ||||||
|         return JournalEntry.query.filter(*conditions)\ |         return JournalEntry.query.join(Transaction).filter(*conditions)\ | ||||||
|  |             .order_by(Transaction.date, | ||||||
|  |                       Transaction.no, | ||||||
|  |                       JournalEntry.is_debit, | ||||||
|  |                       JournalEntry.no)\ | ||||||
|             .options(selectinload(JournalEntry.account), |             .options(selectinload(JournalEntry.account), | ||||||
|                      selectinload(JournalEntry.currency), |                      selectinload(JournalEntry.currency), | ||||||
|                      selectinload(JournalEntry.transaction)).all() |                      selectinload(JournalEntry.transaction)).all() | ||||||
| @@ -92,7 +97,7 @@ class EntryCollector: | |||||||
|                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("Need 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 | ||||||
| @@ -121,7 +126,7 @@ class EntryCollector: | |||||||
|         try: |         try: | ||||||
|             txn_date = datetime.strptime(k, "%Y") |             txn_date = datetime.strptime(k, "%Y") | ||||||
|             conditions.append( |             conditions.append( | ||||||
|                 sa.extract("year", Transaction.date) == txn_date.year) |                 be(sa.extract("year", Transaction.date) == txn_date.year)) | ||||||
|         except ValueError: |         except ValueError: | ||||||
|             pass |             pass | ||||||
|         try: |         try: | ||||||
| @@ -194,7 +199,7 @@ class Search(BaseReport): | |||||||
|         :return: The report as HTML. |         :return: The report as HTML. | ||||||
|         """ |         """ | ||||||
|         pagination: Pagination[JournalEntry] \ |         pagination: Pagination[JournalEntry] \ | ||||||
|             = Pagination[JournalEntry](self.__entries) |             = Pagination[JournalEntry](self.__entries, is_reversed=True) | ||||||
|         params: PageParams = PageParams(pagination=pagination, |         params: PageParams = PageParams(pagination=pagination, | ||||||
|                                         entries=pagination.list) |                                         entries=pagination.list) | ||||||
|         return render_template("accounting/report/search.html", |         return render_template("accounting/report/search.html", | ||||||
|   | |||||||
| @@ -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 { | ||||||
| @@ -113,6 +116,33 @@ | |||||||
|     border-bottom: thick double slategray; |     border-bottom: thick double slategray; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Links between objects */ | ||||||
|  | .accounting-original-entry { | ||||||
|  |     border-top: thin solid darkslategray; | ||||||
|  |     padding: 0.2rem 0.5rem; | ||||||
|  | } | ||||||
|  | .accounting-original-entry a { | ||||||
|  |     color: inherit; | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  | .accounting-original-entry a:hover { | ||||||
|  |     color: inherit; | ||||||
|  | } | ||||||
|  | .accounting-offset-entries { | ||||||
|  |     border-top: thin solid darkslategray; | ||||||
|  |     padding: 0.2rem 0.5rem; | ||||||
|  | } | ||||||
|  | .accounting-offset-entries ul li { | ||||||
|  |     list-style: none; | ||||||
|  | } | ||||||
|  | .accounting-offset-entries ul li a { | ||||||
|  |     color: inherit; | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  | .accounting-offset-entries ul li a:hover { | ||||||
|  |     color: inherit; | ||||||
|  | } | ||||||
|  |  | ||||||
| /** The option selector */ | /** The option selector */ | ||||||
| .accounting-selector-list { | .accounting-selector-list { | ||||||
|     height: 20rem; |     height: 20rem; | ||||||
| @@ -136,9 +166,6 @@ | |||||||
| .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; | ||||||
| } | } | ||||||
| @@ -153,6 +180,9 @@ | |||||||
|     font-weight: bolder; |     font-weight: bolder; | ||||||
|     border-top: thick double slategray; |     border-top: thick double slategray; | ||||||
| } | } | ||||||
|  | .accounting-entry-editor-original-entry-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; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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.includes(this.#query.value)) { | ||||||
|  |                         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; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -22,22 +22,23 @@ | |||||||
|  */ |  */ | ||||||
| "use strict"; | "use strict"; | ||||||
|  |  | ||||||
| // Initializes the page JavaScript. |  | ||||||
| document.addEventListener("DOMContentLoaded", () => { |  | ||||||
|     AccountSelector.initialize(); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * The account selector. |  * The account selector. | ||||||
|  * |  * | ||||||
|  */ |  */ | ||||||
| class AccountSelector { | class AccountSelector { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry editor | ||||||
|  |      * @type {JournalEntryEditor} | ||||||
|  |      */ | ||||||
|  |     #entryEditor; | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The entry type |      * The entry type | ||||||
|      * @type {string} |      * @type {string} | ||||||
|      */ |      */ | ||||||
|     #entryType; |     entryType; | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 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 entryEditor {JournalEntryEditor} the journal entry editor | ||||||
|  |      * @param entryType {string} the entry type, either "debit" or "credit" | ||||||
|      */ |      */ | ||||||
|     constructor(modal) { |     constructor(entryEditor, entryType) { | ||||||
|         this.#entryType = modal.dataset.entryType; |         this.#entryEditor = entryEditor | ||||||
|         this.#prefix = "accounting-account-selector-" + modal.dataset.entryType; |         this.entryType = entryType; | ||||||
|         this.#init(); |         this.#prefix = "accounting-account-selector-" + entryType; | ||||||
|     } |         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.#entryEditor.clearAccount(); | ||||||
|         btnClear.onclick = () => { |         for (const option of this.#options) { | ||||||
|             formAccountControl.classList.remove("accounting-not-empty"); |             option.onclick = () => this.#entryEditor.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,26 +142,24 @@ 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.#entryEditor.form.getAccountCodesUsed(this.entryType); | ||||||
|         const formAccount = document.getElementById("accounting-entry-form-account"); |         if (this.#entryEditor.accountCode !== null) { | ||||||
|         const inUse = [formAccount.dataset.code]; |             inUse.push(this.#entryEditor.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; | ||||||
| @@ -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.#entryEditor.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.#entryEditor.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 entryEditor {JournalEntryEditor} the journal entry editor | ||||||
|  |      * @return {{debit: AccountSelector, credit: AccountSelector}} | ||||||
|      */ |      */ | ||||||
|     static initialize() { |     static getInstances(entryEditor) { | ||||||
|         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.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType); | ||||||
|             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; |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										596
									
								
								src/accounting/static/js/journal-entry-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										596
									
								
								src/accounting/static/js/journal-entry-editor.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,596 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * journal-entry-editor.js: The JavaScript for the journal entry 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 editor. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class JournalEntryEditor { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The transaction form | ||||||
|  |      * @type {TransactionForm} | ||||||
|  |      */ | ||||||
|  |     form; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry editor | ||||||
|  |      * @type {HTMLFormElement} | ||||||
|  |      */ | ||||||
|  |     #element; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The bootstrap modal | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #modal; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The entry type, either "debit" or "credit" | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     entryType; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The prefix of the HTML ID and class | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #prefix = "accounting-entry-editor" | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The container of the original entry | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalEntryContainer; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The control of the original entry | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalEntryControl; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The original entry | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalEntry; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the original entry | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalEntryError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The delete button of the original entry | ||||||
|  |      * @type {HTMLButtonElement} | ||||||
|  |      */ | ||||||
|  |     #originalEntryDelete; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The control of the summary | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #summaryControl; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The summary | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #summary; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the summary | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #summaryError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The control of the account | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #accountControl; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #account; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the account | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #accountError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The amount | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #amount; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the amount | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #amountError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry to edit | ||||||
|  |      * @type {JournalEntrySubForm|null} | ||||||
|  |      */ | ||||||
|  |     entry; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The debit or credit entry side sub-form | ||||||
|  |      * @type {DebitCreditSideSubForm} | ||||||
|  |      */ | ||||||
|  |     #side; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Whether the journal entry needs offset | ||||||
|  |      * @type {boolean} | ||||||
|  |      */ | ||||||
|  |     isNeedOffset = false; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The ID of the original entry | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     originalEntryId = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The date of the original entry | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     originalEntryDate = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The text of the original entry | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     originalEntryText = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account code | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     accountCode = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account text | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     accountText = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The summary | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     summary = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The amount | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     amount = ""; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The summary editors | ||||||
|  |      * @type {{debit: SummaryEditor, credit: SummaryEditor}} | ||||||
|  |      */ | ||||||
|  |     #summaryEditors; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account selectors | ||||||
|  |      * @type {{debit: AccountSelector, credit: AccountSelector}} | ||||||
|  |      */ | ||||||
|  |     #accountSelectors; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The original entry selector | ||||||
|  |      * @type {OriginalEntrySelector} | ||||||
|  |      */ | ||||||
|  |     originalEntrySelector; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs a new journal entry editor. | ||||||
|  |      * | ||||||
|  |      * @param form {TransactionForm} the transaction form | ||||||
|  |      */ | ||||||
|  |     constructor(form) { | ||||||
|  |         this.form = form; | ||||||
|  |         this.#element = document.getElementById(this.#prefix); | ||||||
|  |         this.#modal = document.getElementById(this.#prefix + "-modal"); | ||||||
|  |         this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container"); | ||||||
|  |         this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control"); | ||||||
|  |         this.#originalEntry = document.getElementById(this.#prefix + "-original-entry"); | ||||||
|  |         this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error"); | ||||||
|  |         this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete"); | ||||||
|  |         this.#summaryControl = document.getElementById(this.#prefix + "-summary-control"); | ||||||
|  |         this.#summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         this.#summaryError = document.getElementById(this.#prefix + "-summary-error"); | ||||||
|  |         this.#accountControl = document.getElementById(this.#prefix + "-account-control"); | ||||||
|  |         this.#account = document.getElementById(this.#prefix + "-account"); | ||||||
|  |         this.#accountError = document.getElementById(this.#prefix + "-account-error") | ||||||
|  |         this.#amount = document.getElementById(this.#prefix + "-amount"); | ||||||
|  |         this.#amountError = document.getElementById(this.#prefix + "-amount-error"); | ||||||
|  |         this.#summaryEditors = SummaryEditor.getInstances(this); | ||||||
|  |         this.#accountSelectors = AccountSelector.getInstances(this); | ||||||
|  |         this.originalEntrySelector = new OriginalEntrySelector(); | ||||||
|  |         this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen(this, this.originalEntryId) | ||||||
|  |         this.#originalEntryDelete.onclick = () => this.clearOriginalEntry(); | ||||||
|  |         this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen(); | ||||||
|  |         this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen(); | ||||||
|  |         this.#amount.onchange = () => this.#validateAmount(); | ||||||
|  |         this.#element.onsubmit = () => { | ||||||
|  |             if (this.#validate()) { | ||||||
|  |                 if (this.entry === null) { | ||||||
|  |                     this.entry = this.#side.addJournalEntry(); | ||||||
|  |                 } | ||||||
|  |                 this.amount = this.#amount.value; | ||||||
|  |                 this.entry.save(this); | ||||||
|  |                 bootstrap.Modal.getInstance(this.#modal).hide(); | ||||||
|  |             } | ||||||
|  |             return false; | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Saves the original entry from the original entry selector. | ||||||
|  |      * | ||||||
|  |      * @param originalEntry {OriginalEntry} the original entry | ||||||
|  |      */ | ||||||
|  |     saveOriginalEntry(originalEntry) { | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#originalEntryContainer.classList.remove("d-none"); | ||||||
|  |         this.#originalEntryControl.classList.add("accounting-not-empty"); | ||||||
|  |         this.originalEntryId = originalEntry.id; | ||||||
|  |         this.originalEntryDate = originalEntry.date; | ||||||
|  |         this.originalEntryText = originalEntry.text; | ||||||
|  |         this.#originalEntry.innerText = originalEntry.text; | ||||||
|  |         this.#setEnableSummaryAccount(false); | ||||||
|  |         if (originalEntry.summary === "") { | ||||||
|  |             this.#summaryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#summaryControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.summary = originalEntry.summary === ""? null: originalEntry.summary; | ||||||
|  |         this.#summary.innerText = originalEntry.summary; | ||||||
|  |         this.#accountControl.classList.add("accounting-not-empty"); | ||||||
|  |         this.accountCode = originalEntry.accountCode; | ||||||
|  |         this.accountText = originalEntry.accountText; | ||||||
|  |         this.#account.innerText = originalEntry.accountText; | ||||||
|  |         this.#amount.value = String(originalEntry.netBalance); | ||||||
|  |         this.#amount.max = String(originalEntry.netBalance); | ||||||
|  |         this.#amount.min = "0"; | ||||||
|  |         this.#validate(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Clears the original entry. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     clearOriginalEntry() { | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#originalEntryContainer.classList.add("d-none"); | ||||||
|  |         this.#originalEntryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.originalEntryId = null; | ||||||
|  |         this.originalEntryDate = null; | ||||||
|  |         this.originalEntryText = null; | ||||||
|  |         this.#originalEntry.innerText = ""; | ||||||
|  |         this.#setEnableSummaryAccount(true); | ||||||
|  |         this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.accountCode = null; | ||||||
|  |         this.accountText = null; | ||||||
|  |         this.#account.innerText = ""; | ||||||
|  |         this.#amount.max = ""; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns the currency code. | ||||||
|  |      * | ||||||
|  |      * @return {string} the currency code | ||||||
|  |      */ | ||||||
|  |     getCurrencyCode() { | ||||||
|  |         return this.#side.currency.getCurrencyCode(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Saves the summary from the summary editor. | ||||||
|  |      * | ||||||
|  |      * @param summary {string} the summary | ||||||
|  |      */ | ||||||
|  |     saveSummary(summary) { | ||||||
|  |         if (summary === "") { | ||||||
|  |             this.#summaryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#summaryControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.summary = summary === ""? null: summary; | ||||||
|  |         this.#summary.innerText = summary; | ||||||
|  |         this.#validateSummary(); | ||||||
|  |         bootstrap.Modal.getOrCreateInstance(this.#modal).show(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Saves the summary with the suggested account from the summary editor. | ||||||
|  |      * | ||||||
|  |      * @param summary {string} the summary | ||||||
|  |      * @param accountCode {string} the account code | ||||||
|  |      * @param accountText {string} the account text | ||||||
|  |      * @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise | ||||||
|  |      */ | ||||||
|  |     saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) { | ||||||
|  |         this.isNeedOffset = isAccountNeedOffset; | ||||||
|  |         this.#accountControl.classList.add("accounting-not-empty"); | ||||||
|  |         this.accountCode = accountCode; | ||||||
|  |         this.accountText = accountText; | ||||||
|  |         this.#account.innerText = accountText; | ||||||
|  |         this.#validateAccount(); | ||||||
|  |         this.saveSummary(summary) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Clears the account. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     clearAccount() { | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.accountCode = null; | ||||||
|  |         this.accountText = null; | ||||||
|  |         this.#account.innerText = ""; | ||||||
|  |         this.#validateAccount(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sets the account. | ||||||
|  |      * | ||||||
|  |      * @param code {string} the account code | ||||||
|  |      * @param text {string} the account text | ||||||
|  |      * @param isNeedOffset {boolean} true if the journal entries 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.#account.innerText = text; | ||||||
|  |         this.#validateAccount(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the form. | ||||||
|  |      * | ||||||
|  |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validate() { | ||||||
|  |         let isValid = true; | ||||||
|  |         isValid = this.#validateOriginalEntry() && isValid; | ||||||
|  |         isValid = this.#validateSummary() && isValid; | ||||||
|  |         isValid = this.#validateAccount() && isValid; | ||||||
|  |         isValid = this.#validateAmount() && isValid | ||||||
|  |         return isValid; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the original entry. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      * @private | ||||||
|  |      */ | ||||||
|  |     #validateOriginalEntry() { | ||||||
|  |         this.#originalEntryControl.classList.remove("is-invalid"); | ||||||
|  |         this.#originalEntryError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the summary. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      * @private | ||||||
|  |      */ | ||||||
|  |     #validateSummary() { | ||||||
|  |         this.#summary.classList.remove("is-invalid"); | ||||||
|  |         this.#summaryError.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.#amount.value = this.#amount.value.trim(); | ||||||
|  |         this.#amount.classList.remove("is-invalid"); | ||||||
|  |         if (this.#amount.value === "") { | ||||||
|  |             this.#amount.classList.add("is-invalid"); | ||||||
|  |             this.#amountError.innerText = A_("Please fill in the amount."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         const amount =new Decimal(this.#amount.value); | ||||||
|  |         if (amount.lessThanOrEqualTo(0)) { | ||||||
|  |             this.#amount.classList.add("is-invalid"); | ||||||
|  |             this.#amountError.innerText = A_("Please fill in a positive amount."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (this.#amount.max !== "") { | ||||||
|  |             if (amount.greaterThan(new Decimal(this.#amount.max))) { | ||||||
|  |                 this.#amount.classList.add("is-invalid"); | ||||||
|  |                 this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)}); | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (this.#amount.min !== "") { | ||||||
|  |             const min = new Decimal(this.#amount.min); | ||||||
|  |             if (amount.lessThan(min)) { | ||||||
|  |                 this.#amount.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.#amount.classList.remove("is-invalid"); | ||||||
|  |         this.#amountError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when adding a new journal entry. | ||||||
|  |      * | ||||||
|  |      * @param side {DebitCreditSideSubForm} the debit or credit side sub-form | ||||||
|  |      */ | ||||||
|  |     onAddNew(side) { | ||||||
|  |         this.entry = null; | ||||||
|  |         this.#side = side; | ||||||
|  |         this.entryType = this.#side.entryType; | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#originalEntryContainer.classList.add("d-none"); | ||||||
|  |         this.#originalEntryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.#originalEntryControl.classList.remove("is-invalid"); | ||||||
|  |         this.originalEntryId = null; | ||||||
|  |         this.originalEntryDate = null; | ||||||
|  |         this.originalEntryText = null; | ||||||
|  |         this.#originalEntry.innerText = ""; | ||||||
|  |         this.#setEnableSummaryAccount(true); | ||||||
|  |         this.#summaryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.#summaryControl.classList.remove("is-invalid"); | ||||||
|  |         this.summary = null; | ||||||
|  |         this.#summary.innerText = "" | ||||||
|  |         this.#summaryError.innerText = "" | ||||||
|  |         this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.#accountControl.classList.remove("is-invalid"); | ||||||
|  |         this.accountCode = null; | ||||||
|  |         this.accountText = null; | ||||||
|  |         this.#account.innerText = ""; | ||||||
|  |         this.#accountError.innerText = ""; | ||||||
|  |         this.#amount.value = ""; | ||||||
|  |         this.#amount.max = ""; | ||||||
|  |         this.#amount.min = "0"; | ||||||
|  |         this.#amount.classList.remove("is-invalid"); | ||||||
|  |         this.#amountError.innerText = ""; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when editing a journal entry. | ||||||
|  |      * | ||||||
|  |      * @param entry {JournalEntrySubForm} the journal entry sub-form | ||||||
|  |      */ | ||||||
|  |     onEdit(entry) { | ||||||
|  |         this.entry = entry; | ||||||
|  |         this.#side = entry.side; | ||||||
|  |         this.entryType = this.#side.entryType; | ||||||
|  |         this.isNeedOffset = entry.isNeedOffset(); | ||||||
|  |         this.originalEntryId = entry.getOriginalEntryId(); | ||||||
|  |         this.originalEntryDate = entry.getOriginalEntryDate(); | ||||||
|  |         this.originalEntryText = entry.getOriginalEntryText(); | ||||||
|  |         this.#originalEntry.innerText = this.originalEntryText; | ||||||
|  |         if (this.originalEntryId === null) { | ||||||
|  |             this.#originalEntryContainer.classList.add("d-none"); | ||||||
|  |             this.#originalEntryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#originalEntryContainer.classList.remove("d-none"); | ||||||
|  |             this.#originalEntryControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null); | ||||||
|  |         this.summary = entry.getSummary(); | ||||||
|  |         if (this.summary === null) { | ||||||
|  |             this.#summaryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#summaryControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.#summary.innerText = this.summary === null? "": this.summary; | ||||||
|  |         if (entry.getAccountCode() === null) { | ||||||
|  |             this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#accountControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.accountCode = entry.getAccountCode(); | ||||||
|  |         this.accountText = entry.getAccountText(); | ||||||
|  |         this.#account.innerText = this.accountText; | ||||||
|  |         this.#amount.value = entry.getAmount() === null? "": String(entry.getAmount()); | ||||||
|  |         const maxAmount = this.#getMaxAmount(); | ||||||
|  |         this.#amount.max = maxAmount === null? "": maxAmount; | ||||||
|  |         this.#amount.min = entry.getAmountMin() === null? "": String(entry.getAmountMin()); | ||||||
|  |         this.#validate(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Finds out the max amount. | ||||||
|  |      * | ||||||
|  |      * @return {Decimal|null} the max amount | ||||||
|  |      */ | ||||||
|  |     #getMaxAmount() { | ||||||
|  |         if (this.originalEntryId === null) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sets the enable status of the summary and account. | ||||||
|  |      * | ||||||
|  |      * @param isEnabled {boolean} true to enable, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #setEnableSummaryAccount(isEnabled) { | ||||||
|  |         if (isEnabled) { | ||||||
|  |             this.#summaryControl.dataset.bsToggle = "modal"; | ||||||
|  |             this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal"; | ||||||
|  |             this.#summaryControl.classList.remove("accounting-disabled"); | ||||||
|  |             this.#summaryControl.classList.add("accounting-clickable"); | ||||||
|  |             this.#accountControl.dataset.bsToggle = "modal"; | ||||||
|  |             this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal"; | ||||||
|  |             this.#accountControl.classList.remove("accounting-disabled"); | ||||||
|  |             this.#accountControl.classList.add("accounting-clickable"); | ||||||
|  |         } else { | ||||||
|  |             this.#summaryControl.dataset.bsToggle = ""; | ||||||
|  |             this.#summaryControl.dataset.bsTarget = ""; | ||||||
|  |             this.#summaryControl.classList.add("accounting-disabled"); | ||||||
|  |             this.#summaryControl.classList.remove("accounting-clickable"); | ||||||
|  |             this.#accountControl.dataset.bsToggle = ""; | ||||||
|  |             this.#accountControl.dataset.bsTarget = ""; | ||||||
|  |             this.#accountControl.classList.add("accounting-disabled"); | ||||||
|  |             this.#accountControl.classList.remove("accounting-clickable"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										417
									
								
								src/accounting/static/js/original-entry-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										417
									
								
								src/accounting/static/js/original-entry-selector.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,417 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * original-entry-selector.js: The JavaScript for the original entry 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 entry selector. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class OriginalEntrySelector { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The prefix of the HTML ID and class | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #prefix = "accounting-original-entry-selector"; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The modal of the original entry editor | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #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 {OriginalEntry[]} | ||||||
|  |      */ | ||||||
|  |     #options; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The options by their ID | ||||||
|  |      * @type {Object.<string, OriginalEntry>} | ||||||
|  |      */ | ||||||
|  |     #optionById; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry editor | ||||||
|  |      * @type {JournalEntryEditor} | ||||||
|  |      */ | ||||||
|  |     entryEditor; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The currency code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #currencyCode; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The entry | ||||||
|  |      */ | ||||||
|  |     #entryType; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs an original entry selector. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     constructor() { | ||||||
|  |         this.#modal = document.getElementById(this.#prefix + "-modal"); | ||||||
|  |         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 OriginalEntry(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 entry. | ||||||
|  |      * | ||||||
|  |      * @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing | ||||||
|  |      * @param form {TransactionForm} the transaction form | ||||||
|  |      * @param originalEntryId {string} the ID of the original entry | ||||||
|  |      * @return {Decimal} the net balance of the original entry | ||||||
|  |      */ | ||||||
|  |     getNetBalance(currentEntry, form, originalEntryId) { | ||||||
|  |         const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry); | ||||||
|  |         let otherOffset = new Decimal(0); | ||||||
|  |         for (const otherEntry of otherEntries) { | ||||||
|  |             if (otherEntry.getOriginalEntryId() === originalEntryId) { | ||||||
|  |                 const amount = otherEntry.getAmount(); | ||||||
|  |                 if (amount !== null) { | ||||||
|  |                     otherOffset = otherOffset.plus(amount); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #updateNetBalances() { | ||||||
|  |         const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry); | ||||||
|  |         const otherOffsets = {} | ||||||
|  |         for (const otherEntry of otherEntries) { | ||||||
|  |             const otherOriginalEntryId = otherEntry.getOriginalEntryId(); | ||||||
|  |             const amount = otherEntry.getAmount(); | ||||||
|  |             if (otherOriginalEntryId === null || amount === null) { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |             if (!(otherOriginalEntryId in otherOffsets)) { | ||||||
|  |                 otherOffsets[otherOriginalEntryId] = new Decimal("0"); | ||||||
|  |             } | ||||||
|  |             otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].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.#entryType, 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 entry selector is shown. | ||||||
|  |      * | ||||||
|  |      * @param entryEditor {JournalEntryEditor} the journal entry editor | ||||||
|  |      * @param originalEntryId {string|null} the ID of the original entry | ||||||
|  |      */ | ||||||
|  |     onOpen(entryEditor, originalEntryId = null) { | ||||||
|  |         this.entryEditor = entryEditor | ||||||
|  |         this.#currencyCode = entryEditor.getCurrencyCode(); | ||||||
|  |         this.#entryType = entryEditor.entryType; | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             option.setActive(option.id === originalEntryId); | ||||||
|  |         } | ||||||
|  |         this.#query.value = ""; | ||||||
|  |         this.#updateNetBalances(); | ||||||
|  |         this.#filterOptions(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * An original entry. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class OriginalEntry { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The original entry selector | ||||||
|  |      * @type {OriginalEntrySelector} | ||||||
|  |      */ | ||||||
|  |     #selector; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The element | ||||||
|  |      * @type {HTMLLIElement} | ||||||
|  |      */ | ||||||
|  |     #element; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The ID | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     id; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The date | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     date; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The entry type, either "debit" or "credit" | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #entryType; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The currency code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #currencyCode; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     accountCode; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account text | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     accountText; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The summary | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     summary; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 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 entry | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     text; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The values to query against | ||||||
|  |      * @type {string[][]} | ||||||
|  |      */ | ||||||
|  |     #queryValues; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs an original entry. | ||||||
|  |      * | ||||||
|  |      * @param selector {OriginalEntrySelector} the original entry 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.#entryType = element.dataset.entryType; | ||||||
|  |         this.#currencyCode = element.dataset.currencyCode; | ||||||
|  |         this.accountCode = element.dataset.accountCode; | ||||||
|  |         this.accountText = element.dataset.accountText; | ||||||
|  |         this.summary = element.dataset.summary; | ||||||
|  |         this.bareNetBalance = new Decimal(element.dataset.netBalance); | ||||||
|  |         this.netBalance = this.bareNetBalance; | ||||||
|  |         this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance"); | ||||||
|  |         this.text = element.dataset.text; | ||||||
|  |         this.#queryValues = JSON.parse(element.dataset.queryValues); | ||||||
|  |         this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(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 entryType {string} the entry type, either "debit" or "credit" | ||||||
|  |      * @param currencyCode {string} the currency code | ||||||
|  |      * @param query {string|null} the query term | ||||||
|  |      */ | ||||||
|  |     isMatched(entryType, currencyCode, query = null) { | ||||||
|  |         return this.netBalance.greaterThan(0) | ||||||
|  |             && this.date <= this.#selector.entryEditor.form.getDate() | ||||||
|  |             && this.#isEntryTypeMatches(entryType) | ||||||
|  |             && this.#currencyCode === currencyCode | ||||||
|  |             && this.#isQueryMatches(query); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the original entry matches the entry type. | ||||||
|  |      * | ||||||
|  |      * @param entryType {string} the entry type, either "debit" or credit | ||||||
|  |      * @return {boolean} true if the option matches, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #isEntryTypeMatches(entryType) { | ||||||
|  |         return (entryType === "debit" && this.#entryType === "credit") | ||||||
|  |             || (entryType === "credit" && this.#entryType === "debit"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the original entry 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"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -22,17 +22,18 @@ | |||||||
|  */ |  */ | ||||||
| "use strict"; | "use strict"; | ||||||
|  |  | ||||||
| // Initializes the page JavaScript. |  | ||||||
| document.addEventListener("DOMContentLoaded", () => { |  | ||||||
|     SummaryEditor.initialize(); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * A summary editor. |  * A summary editor. | ||||||
|  * |  * | ||||||
|  */ |  */ | ||||||
| class SummaryEditor { | class SummaryEditor { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry editor | ||||||
|  |      * @type {JournalEntryEditor} | ||||||
|  |      */ | ||||||
|  |     #entryEditor; | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The summary editor form |      * The summary editor form | ||||||
|      * @type {HTMLFormElement} |      * @type {HTMLFormElement} | ||||||
| @@ -55,28 +56,34 @@ class SummaryEditor { | |||||||
|      * The entry type, either "debit" or "credit" |      * The entry type, either "debit" or "credit" | ||||||
|      * @type {string} |      * @type {string} | ||||||
|      */ |      */ | ||||||
|     #entryType; |     entryType; | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The current tab. |      * The current tab | ||||||
|      * @type {TabPlane} |      * @type {TabPlane} | ||||||
|      */ |      */ | ||||||
|     currentTab; |     currentTab; | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The summary input. |      * The summary input | ||||||
|      * @type {HTMLInputElement} |      * @type {HTMLInputElement} | ||||||
|      */ |      */ | ||||||
|     summary; |     summary; | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The number input. |      * The button to the original entry selector | ||||||
|  |      * @type {HTMLButtonElement} | ||||||
|  |      */ | ||||||
|  |     #offsetButton; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The number input | ||||||
|      * @type {HTMLInputElement} |      * @type {HTMLInputElement} | ||||||
|      */ |      */ | ||||||
|     number; |     number; | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The note. |      * The note | ||||||
|      * @type {HTMLInputElement} |      * @type {HTMLInputElement} | ||||||
|      */ |      */ | ||||||
|     note; |     note; | ||||||
| @@ -93,36 +100,6 @@ 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, regular: RegularPaymentTab, annotation: AnnotationTab}} | ||||||
| @@ -132,26 +109,22 @@ class SummaryEditor { | |||||||
|     /** |     /** | ||||||
|      * Constructs a summary editor. |      * Constructs a summary editor. | ||||||
|      * |      * | ||||||
|      * @param form {HTMLFormElement} the summary editor form |      * @param entryEditor {JournalEntryEditor} the journal entry editor | ||||||
|  |      * @param entryType {string} the entry type, either "debit" or "credit" | ||||||
|      */ |      */ | ||||||
|     constructor(form) { |     constructor(entryEditor, entryType) { | ||||||
|         this.#form = form; |         this.#entryEditor = entryEditor; | ||||||
|         this.#entryType = form.dataset.entryType; |         this.entryType = entryType; | ||||||
|         this.prefix = "accounting-summary-editor-" + form.dataset.entryType; |         this.prefix = "accounting-summary-editor-" + entryType; | ||||||
|  |         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.summary = document.getElementById(this.prefix + "-summary"); | ||||||
|  |         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 |  | ||||||
|         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]) { |         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; | ||||||
| @@ -159,6 +132,7 @@ class SummaryEditor { | |||||||
|         this.currentTab = this.tabPlanes.general; |         this.currentTab = this.tabPlanes.general; | ||||||
|         this.#initializeSuggestedAccounts(); |         this.#initializeSuggestedAccounts(); | ||||||
|         this.summary.onchange = () => this.#onSummaryChange(); |         this.summary.onchange = () => this.#onSummaryChange(); | ||||||
|  |         this.#offsetButton.onclick = () => this.#entryEditor.originalEntrySelector.onOpen(this.#entryEditor); | ||||||
|         this.#form.onsubmit = () => { |         this.#form.onsubmit = () => { | ||||||
|             if (this.currentTab.validate()) { |             if (this.currentTab.validate()) { | ||||||
|                 this.#submit(); |                 this.#submit(); | ||||||
| @@ -239,30 +213,21 @@ class SummaryEditor { | |||||||
|      * |      * | ||||||
|      */ |      */ | ||||||
|     #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.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset")); | ||||||
|  |         } else { | ||||||
|  |             this.#entryEditor.saveSummary(this.summary.value); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The callback when the summary editor is shown. |      * The callback when the summary editor is shown. | ||||||
|      * |      * | ||||||
|      */ |      */ | ||||||
|     #onOpen() { |     onOpen() { | ||||||
|         this.#reset(); |         this.#reset(); | ||||||
|         this.summary.value = this.#formSummary.dataset.value; |         this.summary.value = this.#entryEditor.summary === null? "": this.#entryEditor.summary; | ||||||
|         this.#onSummaryChange(); |         this.#onSummaryChange(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -279,33 +244,18 @@ class SummaryEditor { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * The summary editors. |      * Returns the summary editor instances. | ||||||
|      * @type {{debit: SummaryEditor, credit: SummaryEditor}} |  | ||||||
|      */ |  | ||||||
|     static #editors = {} |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Initializes the summary editors. |  | ||||||
|      * |      * | ||||||
|  |      * @param entryEditor {JournalEntryEditor} the journal entry editor | ||||||
|  |      * @return {{debit: SummaryEditor, credit: SummaryEditor}} | ||||||
|      */ |      */ | ||||||
|     static initialize() { |     static getInstances(entryEditor) { | ||||||
|  |         const editors = {} | ||||||
|         const forms = Array.from(document.getElementsByClassName("accounting-summary-editor")); |         const forms = Array.from(document.getElementsByClassName("accounting-summary-editor")); | ||||||
|         const entryForm = document.getElementById("accounting-entry-form"); |  | ||||||
|         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.entryType] = new SummaryEditor(entryEditor, form.dataset.entryType); | ||||||
|             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(); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -547,7 +497,7 @@ class TagTabPlane extends TabPlane { | |||||||
|         errorContainer.innerText = ""; |         errorContainer.innerText = ""; | ||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     /** |     /** | ||||||
|      * Resets the tab plane input. |      * Resets the tab plane input. | ||||||
|      * |      * | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -85,7 +85,7 @@ First written: 2023/1/31 | |||||||
| <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 }}</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_("Need offset") }}</span> | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -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,9 +62,9 @@ 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 entries 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,7 +58,7 @@ 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_("Need offset") }}</span> | ||||||
|       {% endif %} |       {% endif %} | ||||||
|     </a> |     </a> | ||||||
|   | |||||||
| @@ -23,4 +23,6 @@ First written: 2023/3/8 | |||||||
| <div>{{ entry.summary|accounting_default }}</div> | <div>{{ entry.summary|accounting_default }}</div> | ||||||
| <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> | <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> | ||||||
| <div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> | <div class="accounting-amount">{{ entry.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 entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> | ||||||
|  | {% endif %} | ||||||
|   | |||||||
| @@ -37,5 +37,7 @@ First written: 2023/3/5 | |||||||
|   {% if entry.credit %} |   {% if entry.credit %} | ||||||
|     <span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span> |     <span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span> |   {% if report.account.is_real %} | ||||||
|  |     <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span> | ||||||
|  |   {% endif %} | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -49,14 +49,16 @@ First written: 2023/3/5 | |||||||
|     {% include "accounting/include/pagination.html" %} |     {% include "accounting/include/pagination.html" %} | ||||||
|   {% endwith %} |   {% endwith %} | ||||||
|  |  | ||||||
|   <div class="d-none d-md-block accounting-report-table accounting-ledger-table"> |   <div class="d-none d-md-block accounting-report-table {% if report.account.is_real %} accounting-ledger-real-table {% else %} accounting-ledger-nominal-table {% endif %}"> | ||||||
|     <div class="accounting-report-table-header"> |     <div class="accounting-report-table-header"> | ||||||
|       <div class="accounting-report-table-row"> |       <div class="accounting-report-table-row"> | ||||||
|         <div>{{ A_("Date") }}</div> |         <div>{{ A_("Date") }}</div> | ||||||
|         <div>{{ A_("Summary") }}</div> |         <div>{{ A_("Summary") }}</div> | ||||||
|         <div class="accounting-amount">{{ A_("Debit") }}</div> |         <div class="accounting-amount">{{ A_("Debit") }}</div> | ||||||
|         <div class="accounting-amount">{{ A_("Credit") }}</div> |         <div class="accounting-amount">{{ A_("Credit") }}</div> | ||||||
|         <div class="accounting-amount">{{ A_("Balance") }}</div> |         {% if report.account.is_real %} | ||||||
|  |           <div class="accounting-amount">{{ A_("Balance") }}</div> | ||||||
|  |         {% endif %} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <div class="accounting-report-table-body"> |     <div class="accounting-report-table-body"> | ||||||
| @@ -80,7 +82,9 @@ First written: 2023/3/5 | |||||||
|             <div>{{ A_("Total") }}</div> |             <div>{{ A_("Total") }}</div> | ||||||
|             <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> |             <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> | ||||||
|             <div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> |             <div class="accounting-amount">{{ entry.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 entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> | ||||||
|  |             {% endif %} | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       {% endwith %} |       {% endwith %} | ||||||
|   | |||||||
| @@ -35,19 +35,9 @@ First written: 2023/2/26 | |||||||
|  |  | ||||||
|       <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-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li> | ||||||
|         {% for entry in currency.debit %} |         {% with entries = currency.debit %} | ||||||
|           <li class="list-group-item accounting-transaction-entry"> |           {% include "accounting/transaction/include/detail-entries.html" %} | ||||||
|             <div class="d-flex justify-content-between"> |         {% endwith %} | ||||||
|               <div> |  | ||||||
|                 <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"> |         <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> | ||||||
|   | |||||||
| @@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/2/25 | First written: 2023/2/25 | ||||||
| #} | #} | ||||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}"> | <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 }}-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 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="d-flex justify-content-between mt-2 mb-3"> | ||||||
|       <div class="form-floating accounting-currency-content"> |       <div class="form-floating accounting-currency-content"> | ||||||
|         <select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code"> |         <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}> | ||||||
|           {% for currency in accounting_currency_options() %} |           {% for currency in accounting_currency_options() %} | ||||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> |             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||||
|           {% endfor %} |           {% endfor %} | ||||||
|         </select> |         </select> | ||||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label> |         <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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       <div> |       <div> | ||||||
|         <button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> |         <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> |           <i class="fas fa-minus"></i> | ||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
| @@ -55,9 +56,17 @@ First written: 2023/2/25 | |||||||
|                     account_text = entry_form.account_text, |                     account_text = entry_form.account_text, | ||||||
|                     summary_data = entry_form.summary.data|accounting_default, |                     summary_data = entry_form.summary.data|accounting_default, | ||||||
|                     summary_errors = entry_form.summary.errors, |                     summary_errors = entry_form.summary.errors, | ||||||
|  |                     original_entry_id_data = entry_form.original_entry_id.data|accounting_default, | ||||||
|  |                     original_entry_date = entry_form.original_entry_date|accounting_default, | ||||||
|  |                     original_entry_text = entry_form.original_entry_text|accounting_default, | ||||||
|  |                     is_need_offset = entry_form.is_need_offset, | ||||||
|  |                     offset_entries = entry_form.offsets, | ||||||
|  |                     offset_total = entry_form.offset_total|accounting_default("0"), | ||||||
|  |                     net_balance_data = entry_form.net_balance, | ||||||
|  |                     net_balance_text = entry_form.net_balance|accounting_format_amount, | ||||||
|                     amount_data = entry_form.amount.data|accounting_txn_format_amount_input, |                     amount_data = entry_form.amount.data|accounting_txn_format_amount_input, | ||||||
|                     amount_errors = entry_form.amount.errors, |                     amount_errors = entry_form.amount.errors, | ||||||
|                     amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), |                     amount_text = entry_form.amount.data|accounting_format_amount, | ||||||
|                     entry_errors = entry_form.all_errors %} |                     entry_errors = entry_form.all_errors %} | ||||||
|               {% include "accounting/transaction/include/form-entry-item.html" %} |               {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|             {% endwith %} |             {% endwith %} | ||||||
| @@ -70,7 +79,7 @@ First written: 2023/2/25 | |||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <div> |         <div> | ||||||
|           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |           <button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> | ||||||
|             <i class="fas fa-plus"></i> |             <i class="fas fa-plus"></i> | ||||||
|             {{ A_("New") }} |             {{ A_("New") }} | ||||||
|           </button> |           </button> | ||||||
|   | |||||||
| @@ -29,6 +29,7 @@ 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.form.debit_total|accounting_format_amount %} | ||||||
|   | |||||||
| @@ -19,12 +19,12 @@ account-selector-modal.html: The modal for the account selector | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/2/25 | First written: 2023/2/25 | ||||||
| #} | #} | ||||||
| <div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true"> | <div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true"> | ||||||
|   <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-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1> |         <h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1> | ||||||
|         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> |         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|       </div> |       </div> | ||||||
|       <div class="modal-body"> |       <div class="modal-body"> | ||||||
|         <div class="input-group mb-2"> |         <div class="input-group mb-2"> | ||||||
| @@ -37,17 +37,17 @@ First written: 2023/2/25 | |||||||
|  |  | ||||||
|         <ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list"> |         <ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list"> | ||||||
|           {% for account in account_options %} |           {% for account in account_options %} | ||||||
|           <li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |             <li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-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-entry-editor-modal"> | ||||||
|             {{ account }} |               {{ account }} | ||||||
|           </li> |             </li> | ||||||
|           {% endfor %} |           {% endfor %} | ||||||
|           <li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> |           <li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> | ||||||
|         </ul> |         </ul> | ||||||
|         <p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> |         <p id="accounting-account-selector-{{ entry_type }}-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-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button> |         <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button> | ||||||
|         <button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button> |         <button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Clear") }}</button> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -0,0 +1,75 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | detail-entries-item: The journal entries in the transaction 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 entry in entries %} | ||||||
|  |   <li class="list-group-item accounting-transaction-entry"> | ||||||
|  |     <div class="d-flex justify-content-between"> | ||||||
|  |       <div> | ||||||
|  |         <div class="small">{{ entry.account }}</div> | ||||||
|  |         {% if entry.summary is not none %} | ||||||
|  |           <div>{{ entry.summary }}</div> | ||||||
|  |         {% endif %} | ||||||
|  |         {% if entry.original_entry %} | ||||||
|  |           <div class="fst-italic small accounting-original-entry"> | ||||||
|  |             <a href="{{ url_for("accounting.transaction.detail", txn=entry.original_entry.transaction)|accounting_append_next }}"> | ||||||
|  |               {{ A_("Offset %(entry)s", entry=entry.original_entry) }} | ||||||
|  |             </a> | ||||||
|  |           </div> | ||||||
|  |         {% endif %} | ||||||
|  |         {% if entry.is_need_offset %} | ||||||
|  |           <div class="fst-italic small accounting-offset-entries"> | ||||||
|  |             {% if entry.offsets %} | ||||||
|  |               <div class="d-flex justify-content-between"> | ||||||
|  |                 <div>{{ A_("Offsets") }}</div> | ||||||
|  |                 <ul class="ms-2 ps-0"> | ||||||
|  |                   {% for offset in entry.offsets %} | ||||||
|  |                     <li> | ||||||
|  |                       <a href="{{ url_for("accounting.transaction.detail", txn=offset.transaction)|accounting_append_next }}"> | ||||||
|  |                         {{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }} | ||||||
|  |                       </a> | ||||||
|  |                     </li> | ||||||
|  |                   {% endfor %} | ||||||
|  |                 </ul> | ||||||
|  |               </div> | ||||||
|  |               {% if entry.balance %} | ||||||
|  |                 <div class="d-flex justify-content-between"> | ||||||
|  |                   <div>{{ A_("Net balance") }}</div> | ||||||
|  |                   <div>{{ entry.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>{{ entry.amount|accounting_format_amount }}</div> | ||||||
|  |     </div> | ||||||
|  |   </li> | ||||||
|  | {% endfor %} | ||||||
|  | {# </ul> For SonarQube not to complain about incorrect HTML #} | ||||||
| @@ -42,10 +42,17 @@ First written: 2023/2/26 | |||||||
|   </a> |   </a> | ||||||
|   {% if accounting_can_edit() %} |   {% if accounting_can_edit() %} | ||||||
|     {% block to_transfer %}{% endblock %} |     {% block to_transfer %}{% 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> |         {{ A_("Delete") }} | ||||||
|  |       </button> | ||||||
|  |     {% else %} | ||||||
|  |       <button class="btn btn-secondary" type="button" disabled="disabled"> | ||||||
|  |         <i class="fa-solid fa-trash"></i> | ||||||
|  |         {{ A_("Delete") }} | ||||||
|  |       </button> | ||||||
|  |     {% endif %} | ||||||
|   {% endif %} |   {% endif %} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| @@ -57,7 +64,7 @@ First written: 2023/2/26 | |||||||
|   </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.transaction.delete", txn=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 %} | ||||||
|   | |||||||
| @@ -1,60 +0,0 @@ | |||||||
| {# |  | ||||||
| The Mia! Accounting Flask Project |  | ||||||
| entry-form-modal.html: The modal of the journal entry sub-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 |  | ||||||
| #} |  | ||||||
| <form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index=""> |  | ||||||
|   <div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-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-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1> |  | ||||||
|           <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> |  | ||||||
|         </div> |  | ||||||
|         <div class="modal-body"> |  | ||||||
|           <div class="mb-3"> |  | ||||||
|             <div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target=""> |  | ||||||
|               <label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label> |  | ||||||
|               <div id="accounting-entry-form-account" data-code="" data-text=""></div> |  | ||||||
|             </div> |  | ||||||
|             <div id="accounting-entry-form-account-error" class="invalid-feedback"></div> |  | ||||||
|           </div> |  | ||||||
|  |  | ||||||
|           <div class="mb-3"> |  | ||||||
|             <div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target=""> |  | ||||||
|               <label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label> |  | ||||||
|               <div id="accounting-entry-form-summary" data-value=""></div> |  | ||||||
|             </div> |  | ||||||
|             <div id="accounting-entry-form-summary-error" class="invalid-feedback"></div> |  | ||||||
|           </div> |  | ||||||
|  |  | ||||||
|           <div class="form-floating mb-3"> |  | ||||||
|             <input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required"> |  | ||||||
|             <label for="accounting-entry-form-amount">{{ A_("Amount") }}</label> |  | ||||||
|             <div id="accounting-entry-form-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 id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </form> |  | ||||||
| @@ -20,19 +20,45 @@ Author: imacat@mail.imacat.idv.tw (imacat) | |||||||
| First written: 2023/2/25 | First written: 2023/2/25 | ||||||
| #} | #} | ||||||
| {# <ul> For SonarQube not to complain about incorrect HTML #} | {# <ul> For SonarQube not to complain about incorrect HTML #} | ||||||
| <li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}"> | <li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ entry_type }} {% if offset_entries %} accounting-matched-entry {% endif %}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" {% if is_need_offset %} data-is-need-offset="true" {% endif %}> | ||||||
|   {% if entry_id %} |   {% if entry_id %} | ||||||
|     <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}"> |     <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}"> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}"> | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-id" class="accounting-original-entry-id" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original_entry_id" value="{{ original_entry_id_data }}" data-date="{{ original_entry_date }}" data-text="{{ original_entry_text }}"> | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}"> | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}"> | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}" data-min="{{ offset_total }}"> | ||||||
|   <div class="accounting-entry-content"> |   <div class="accounting-entry-content"> | ||||||
|     <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |     <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> | ||||||
|       <div> |       <div> | ||||||
|         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div> |         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div> | ||||||
|         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div> |         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-text" class="fst-italic small accounting-original-entry {% if not original_entry_text %} d-none {% endif %}"> | ||||||
|  |           {% if original_entry_text %}{{ A_("Offset %(entry)s", entry=original_entry_text) }}{% endif %} | ||||||
|  |         </div> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-offsets" class="fst-italic small accounting-offset-entries {% if not is_need_offset %} d-none {% endif %}"> | ||||||
|  |           {% if offset_entries %} | ||||||
|  |             <div class="d-flex justify-content-between {% if not offset_entries %} d-none {% endif %}"> | ||||||
|  |               <div>{{ A_("Offsets") }}</div> | ||||||
|  |               <ul class="ms-2 ps-0"> | ||||||
|  |                 {% for offset in offset_entries %} | ||||||
|  |                   <li>{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li> | ||||||
|  |                 {% endfor %} | ||||||
|  |               </ul> | ||||||
|  |             </div> | ||||||
|  |             {% if net_balance_data == 0 %} | ||||||
|  |               <div>{{ A_("Fully offset") }}</div> | ||||||
|  |             {% else %} | ||||||
|  |               <div class="d-flex justify-content-between"> | ||||||
|  |                 <div>{{ A_("Net balance") }}</div> | ||||||
|  |                 <div>{{ net_balance_text }}</div> | ||||||
|  |               </div> | ||||||
|  |             {% endif %} | ||||||
|  |           {% else %} | ||||||
|  |             {{ A_("Unmatched") }} | ||||||
|  |           {% endif %} | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
|       <div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div> |       <div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div> | ||||||
|     </div> |     </div> | ||||||
| @@ -40,7 +66,7 @@ First written: 2023/2/25 | |||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div> |   <div> | ||||||
|     <button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry"> |     <button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form or offset_entries %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}"> | ||||||
|       <i class="fas fa-minus"></i> |       <i class="fas fa-minus"></i> | ||||||
|     </button> |     </button> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -24,7 +24,9 @@ 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/transaction-form.js") }}"></script> | ||||||
|  |   <script src="{{ url_for("accounting.static", filename="js/journal-entry-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/original-entry-selector.js") }}"></script> | ||||||
|   <script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script> |   <script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| @@ -44,7 +46,7 @@ First written: 2023/2/26 | |||||||
|   {% 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/transaction/include/journal-entry-editor-modal.html" %} | ||||||
| {% block form_modals %}{% endblock %} | {% block form_modals %}{% endblock %} | ||||||
|  | {% include "accounting/transaction/include/original-entry-selector-modal.html" %} | ||||||
|  |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -0,0 +1,76 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | journal-entry-editor-modal.html: The modal of the journal entry 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-entry-editor"> | ||||||
|  |   <div id="accounting-entry-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-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-entry-editor-modal-label">{{ A_("Journal Entry 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-entry-editor-original-entry-container" class="d-flex justify-content-between mb-3"> | ||||||
|  |             <div class="accounting-entry-editor-original-entry-content"> | ||||||
|  |               <div id="accounting-entry-editor-original-entry-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal"> | ||||||
|  |                 <label class="form-label" for="accounting-entry-editor-original-entry">{{ A_("Original Entry") }}</label> | ||||||
|  |                 <div id="accounting-entry-editor-original-entry"></div> | ||||||
|  |               </div> | ||||||
|  |               <div id="accounting-entry-editor-original-entry-error" class="invalid-feedback"></div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               <button id="accounting-entry-editor-original-entry-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-entry-editor-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target=""> | ||||||
|  |               <label class="form-label" for="accounting-entry-editor-summary">{{ A_("Summary") }}</label> | ||||||
|  |               <div id="accounting-entry-editor-summary"></div> | ||||||
|  |             </div> | ||||||
|  |             <div id="accounting-entry-editor-summary-error" class="invalid-feedback"></div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div class="mb-3"> | ||||||
|  |             <div id="accounting-entry-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-entry-editor-account">{{ A_("Account") }}</label> | ||||||
|  |               <div id="accounting-entry-editor-account"></div> | ||||||
|  |             </div> | ||||||
|  |             <div id="accounting-entry-editor-account-error" class="invalid-feedback"></div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div class="form-floating mb-3"> | ||||||
|  |             <input id="accounting-entry-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required"> | ||||||
|  |             <label for="accounting-entry-editor-amount">{{ A_("Amount") }}</label> | ||||||
|  |             <div id="accounting-entry-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,56 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | original-entry-selector-modal.html: The modal of the original entry 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-entry-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-entry-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-entry-selector-modal-label">{{ A_("Select Original Entry") }}</h1> | ||||||
|  |         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|  |       </div> | ||||||
|  |       <div class="modal-body"> | ||||||
|  |         <div class="input-group mb-2"> | ||||||
|  |           <input id="accounting-original-entry-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> | ||||||
|  |           <label class="input-group-text" for="accounting-original-entry-selector-query"> | ||||||
|  |             <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |             {{ A_("Search") }} | ||||||
|  |           </label> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <ul id="accounting-original-entry-selector-option-list" class="list-group accounting-selector-list"> | ||||||
|  |           {% for entry in form.original_entry_options %} | ||||||
|  |             <li id="accounting-original-entry-selector-option-{{ entry.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-entry-selector-option" data-id="{{ entry.id }}" data-date="{{ entry.transaction.date }}" data-entry-type="{{ "debit" if entry.is_debit else "credit" }}" data-currency-code="{{ entry.currency.code }}" data-account-code="{{ entry.account_code }}" data-account-text="{{ entry.account }}" data-summary="{{ entry.summary|accounting_default }}" data-net-balance="{{ entry.net_balance|accounting_txn_format_amount_input }}" data-text="{{ entry }}" data-query-values="{{ entry.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> | ||||||
|  |               <div>{{ entry.transaction.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div> | ||||||
|  |               <div> | ||||||
|  |                 <span class="badge bg-primary rounded-pill"> | ||||||
|  |                   <span id="accounting-original-entry-selector-option-{{ entry.id }}-net-balance">{{ entry.net_balance|accounting_format_amount }}</span> | ||||||
|  |                   / {{ entry.amount|accounting_format_amount }} | ||||||
|  |                 </span> | ||||||
|  |               </div> | ||||||
|  |             </li> | ||||||
|  |           {% endfor %} | ||||||
|  |         </ul> | ||||||
|  |         <p id="accounting-original-entry-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| @@ -27,11 +27,14 @@ First written: 2023/2/28 | |||||||
|           <h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> |           <h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> | ||||||
|             <label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label> |             <label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label> | ||||||
|           </h1> |           </h1> | ||||||
|           <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> |           <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|         </div> |         </div> | ||||||
|         <div class="modal-body"> |         <div class="modal-body"> | ||||||
|           <div class="mb-3"> |           <div class="d-flex justify-content-between mb-3"> | ||||||
|             <input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> |             <input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> | ||||||
|  |             <button id="accounting-summary-editor-{{ summary_editor.type }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal"> | ||||||
|  |               {{ A_("Offset...") }} | ||||||
|  |             </button> | ||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           {# Tab navigation #} |           {# Tab navigation #} | ||||||
| @@ -174,14 +177,14 @@ First written: 2023/2/28 | |||||||
|           {# The suggested accounts #} |           {# The suggested accounts #} | ||||||
|           <div class="mt-3"> |           <div class="mt-3"> | ||||||
|             {% for account in summary_editor.accounts %} |             {% for account in summary_editor.accounts %} | ||||||
|               <button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> |               <button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> | ||||||
|                 {{ account }} |                 {{ account }} | ||||||
|               </button> |               </button> | ||||||
|             {% endfor %} |             {% endfor %} | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <div class="modal-footer"> |         <div class="modal-footer"> | ||||||
|           <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button> |           <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button> | ||||||
|           <button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> |           <button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|   | |||||||
| @@ -35,19 +35,9 @@ First written: 2023/2/26 | |||||||
|  |  | ||||||
|       <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-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li> | ||||||
|         {% for entry in currency.credit %} |         {% with entries = currency.credit %} | ||||||
|           <li class="list-group-item accounting-transaction-entry"> |           {% include "accounting/transaction/include/detail-entries.html" %} | ||||||
|             <div class="d-flex justify-content-between"> |         {% endwith %} | ||||||
|               <div> |  | ||||||
|                 <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"> |         <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> | ||||||
|   | |||||||
| @@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash income transaction for | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/2/25 | First written: 2023/2/25 | ||||||
| #} | #} | ||||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}"> | <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 }}-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 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="d-flex justify-content-between mt-2 mb-3"> | ||||||
|       <div class="form-floating accounting-currency-content"> |       <div class="form-floating accounting-currency-content"> | ||||||
|         <select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code"> |         <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}> | ||||||
|           {% for currency in accounting_currency_options() %} |           {% for currency in accounting_currency_options() %} | ||||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> |             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||||
|           {% endfor %} |           {% endfor %} | ||||||
|         </select> |         </select> | ||||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label> |         <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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       <div> |       <div> | ||||||
|         <button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> |         <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> |           <i class="fas fa-minus"></i> | ||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
| @@ -55,9 +56,17 @@ First written: 2023/2/25 | |||||||
|                     account_text = entry_form.account_text, |                     account_text = entry_form.account_text, | ||||||
|                     summary_data = entry_form.summary.data|accounting_default, |                     summary_data = entry_form.summary.data|accounting_default, | ||||||
|                     summary_errors = entry_form.summary.errors, |                     summary_errors = entry_form.summary.errors, | ||||||
|  |                     original_entry_id_data = entry_form.original_entry_id.data|accounting_default, | ||||||
|  |                     original_entry_date = entry_form.original_entry_date|accounting_default, | ||||||
|  |                     original_entry_text = entry_form.original_entry_text|accounting_default, | ||||||
|  |                     is_need_offset = entry_form.is_need_offset, | ||||||
|  |                     offset_entries = entry_form.offsets, | ||||||
|  |                     offset_total = entry_form.offset_total|accounting_default("0"), | ||||||
|  |                     net_balance_data = entry_form.net_balance, | ||||||
|  |                     net_balance_text = entry_form.net_balance|accounting_format_amount, | ||||||
|                     amount_data = entry_form.amount.data|accounting_txn_format_amount_input, |                     amount_data = entry_form.amount.data|accounting_txn_format_amount_input, | ||||||
|                     amount_errors = entry_form.amount.errors, |                     amount_errors = entry_form.amount.errors, | ||||||
|                     amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), |                     amount_text = entry_form.amount.data|accounting_format_amount, | ||||||
|                     entry_errors = entry_form.all_errors %} |                     entry_errors = entry_form.all_errors %} | ||||||
|               {% include "accounting/transaction/include/form-entry-item.html" %} |               {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|             {% endwith %} |             {% endwith %} | ||||||
| @@ -70,7 +79,7 @@ First written: 2023/2/25 | |||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <div> |         <div> | ||||||
|           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |           <button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> | ||||||
|             <i class="fas fa-plus"></i> |             <i class="fas fa-plus"></i> | ||||||
|             {{ A_("New") }} |             {{ A_("New") }} | ||||||
|           </button> |           </button> | ||||||
|   | |||||||
| @@ -29,6 +29,7 @@ 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.form.credit_total|accounting_format_amount %} | ||||||
|   | |||||||
| @@ -31,19 +31,9 @@ First written: 2023/2/26 | |||||||
|         <div class="col-sm-6 mb-2"> |         <div class="col-sm-6 mb-2"> | ||||||
|           <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_("Debit") }}</li> |             <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li> | ||||||
|             {% for entry in currency.debit %} |             {% with entries = currency.debit %} | ||||||
|               <li class="list-group-item accounting-transaction-entry"> |               {% include "accounting/transaction/include/detail-entries.html" %} | ||||||
|                 <div class="d-flex justify-content-between"> |             {% endwith %} | ||||||
|                   <div> |  | ||||||
|                     <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"> |             <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> | ||||||
| @@ -57,19 +47,9 @@ First written: 2023/2/26 | |||||||
|         <div class="col-sm-6 mb-2"> |         <div class="col-sm-6 mb-2"> | ||||||
|           <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_("Credit") }}</li> |             <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li> | ||||||
|             {% for entry in currency.credit %} |             {% with entries = currency.credit %} | ||||||
|               <li class="list-group-item accounting-transaction-entry"> |               {% include "accounting/transaction/include/detail-entries.html" %} | ||||||
|                 <div class="d-flex justify-content-between"> |             {% endwith %} | ||||||
|                   <div> |  | ||||||
|                     <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"> |             <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> | ||||||
|   | |||||||
| @@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in 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 | ||||||
| #} | #} | ||||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}"> | <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 }}-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 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="d-flex justify-content-between mt-2 mb-3"> | ||||||
|       <div class="form-floating accounting-currency-content"> |       <div class="form-floating accounting-currency-content"> | ||||||
|         <select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code"> |         <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() %} |           {% for currency in accounting_currency_options() %} | ||||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> |             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||||
|           {% endfor %} |           {% endfor %} | ||||||
|         </select> |         </select> | ||||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label> |         <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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       <div> |       <div> | ||||||
|         <button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> |         <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> |           <i class="fas fa-minus"></i> | ||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
| @@ -57,9 +58,17 @@ First written: 2023/2/25 | |||||||
|                       account_text = entry_form.account_text, |                       account_text = entry_form.account_text, | ||||||
|                       summary_data = entry_form.summary.data|accounting_default, |                       summary_data = entry_form.summary.data|accounting_default, | ||||||
|                       summary_errors = entry_form.summary.errors, |                       summary_errors = entry_form.summary.errors, | ||||||
|  |                       original_entry_id_data = entry_form.original_entry_id.data|accounting_default, | ||||||
|  |                       original_entry_date = entry_form.original_entry_date|accounting_default, | ||||||
|  |                       original_entry_text = entry_form.original_entry_text|accounting_default, | ||||||
|  |                       is_need_offset = entry_form.is_need_offset, | ||||||
|  |                       offset_entries = entry_form.offsets, | ||||||
|  |                       offset_total = entry_form.offset_total|accounting_default, | ||||||
|  |                       net_balance_data = entry_form.net_balance, | ||||||
|  |                       net_balance_text = entry_form.net_balance|accounting_format_amount, | ||||||
|                       amount_data = entry_form.amount.data|accounting_txn_format_amount_input, |                       amount_data = entry_form.amount.data|accounting_txn_format_amount_input, | ||||||
|                       amount_errors = entry_form.amount.errors, |                       amount_errors = entry_form.amount.errors, | ||||||
|                       amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), |                       amount_text = entry_form.amount.data|accounting_format_amount, | ||||||
|                       entry_errors = entry_form.all_errors %} |                       entry_errors = entry_form.all_errors %} | ||||||
|                 {% include "accounting/transaction/include/form-entry-item.html" %} |                 {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|               {% endwith %} |               {% endwith %} | ||||||
| @@ -72,7 +81,7 @@ First written: 2023/2/25 | |||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           <div> |           <div> | ||||||
|             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |             <button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> | ||||||
|               <i class="fas fa-plus"></i> |               <i class="fas fa-plus"></i> | ||||||
|               {{ A_("New") }} |               {{ A_("New") }} | ||||||
|             </button> |             </button> | ||||||
| @@ -88,18 +97,26 @@ First written: 2023/2/25 | |||||||
|           <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> |           <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> | ||||||
|             {% for entry_form in credit_forms %} |             {% for entry_form in credit_forms %} | ||||||
|               {% with currency_index = currency_index, |               {% with currency_index = currency_index, | ||||||
|                       entry_id = entry_form.eid.data, |  | ||||||
|                       entry_type = "credit", |                       entry_type = "credit", | ||||||
|                       entry_index = loop.index, |                       entry_index = loop.index, | ||||||
|                       only_one_entry_form = debit_forms|length == 1, |                       only_one_entry_form = debit_forms|length == 1, | ||||||
|  |                       entry_id = entry_form.eid.data, | ||||||
|                       account_code_data = entry_form.account_code.data|accounting_default, |                       account_code_data = entry_form.account_code.data|accounting_default, | ||||||
|                       account_code_error = entry_form.account_code.errors, |                       account_code_error = entry_form.account_code.errors, | ||||||
|                       account_text = entry_form.account_text, |                       account_text = entry_form.account_text, | ||||||
|                       summary_data = entry_form.summary.data|accounting_default, |                       summary_data = entry_form.summary.data|accounting_default, | ||||||
|                       summary_errors = entry_form.summary.errors, |                       summary_errors = entry_form.summary.errors, | ||||||
|  |                       original_entry_id_data = entry_form.original_entry_id.data|accounting_default, | ||||||
|  |                       original_entry_date = entry_form.original_entry_date|accounting_default, | ||||||
|  |                       original_entry_text = entry_form.original_entry_text|accounting_default, | ||||||
|  |                       is_need_offset = entry_form.is_need_offset, | ||||||
|  |                       offset_entries = entry_form.offsets, | ||||||
|  |                       offset_total = entry_form.offset_total|accounting_default("0"), | ||||||
|  |                       net_balance_data = entry_form.net_balance, | ||||||
|  |                       net_balance_text = entry_form.net_balance|accounting_format_amount, | ||||||
|                       amount_data = entry_form.amount.data|accounting_txn_format_amount_input, |                       amount_data = entry_form.amount.data|accounting_txn_format_amount_input, | ||||||
|                       amount_errors = entry_form.amount.errors, |                       amount_errors = entry_form.amount.errors, | ||||||
|                       amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), |                       amount_text = entry_form.amount.data|accounting_format_amount, | ||||||
|                       entry_errors = entry_form.all_errors %} |                       entry_errors = entry_form.all_errors %} | ||||||
|                 {% include "accounting/transaction/include/form-entry-item.html" %} |                 {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|               {% endwith %} |               {% endwith %} | ||||||
| @@ -112,7 +129,7 @@ First written: 2023/2/25 | |||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           <div> |           <div> | ||||||
|             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |             <button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> | ||||||
|               <i class="fas fa-plus"></i> |               <i class="fas fa-plus"></i> | ||||||
|               {{ A_("New") }} |               {{ A_("New") }} | ||||||
|             </button> |             </button> | ||||||
|   | |||||||
| @@ -29,6 +29,7 @@ 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.form.debit_total|accounting_format_amount, | ||||||
|   | |||||||
| @@ -20,10 +20,10 @@ | |||||||
| from datetime import date | from datetime import date | ||||||
|  |  | ||||||
| from flask import abort | from flask import abort | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
| from werkzeug.routing import BaseConverter | from werkzeug.routing import BaseConverter | ||||||
|  |  | ||||||
| from accounting import db | from accounting.models import Transaction, JournalEntry | ||||||
| from accounting.models import Transaction |  | ||||||
| from accounting.utils.txn_types import TransactionType | from accounting.utils.txn_types import TransactionType | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -37,7 +37,13 @@ class TransactionConverter(BaseConverter): | |||||||
|         :param value: The transaction ID. |         :param value: The transaction ID. | ||||||
|         :return: The corresponding transaction. |         :return: The corresponding transaction. | ||||||
|         """ |         """ | ||||||
|         transaction: Transaction | None = db.session.get(Transaction, value) |         transaction: Transaction | None = Transaction.query\ | ||||||
|  |             .join(JournalEntry)\ | ||||||
|  |             .filter(Transaction.id == value)\ | ||||||
|  |             .options(selectinload(Transaction.entries) | ||||||
|  |                      .selectinload(JournalEntry.offsets) | ||||||
|  |                      .selectinload(JournalEntry.transaction))\ | ||||||
|  |             .first() | ||||||
|         if transaction is None: |         if transaction is None: | ||||||
|             abort(404) |             abort(404) | ||||||
|         return transaction |         return transaction | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								src/accounting/transaction/forms/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/accounting/transaction/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 transaction management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from .reorder import sort_transactions_in, TransactionReorderForm | ||||||
|  | from .transaction import TransactionForm, IncomeTransactionForm, \ | ||||||
|  |     ExpenseTransactionForm, TransferTransactionForm | ||||||
							
								
								
									
										294
									
								
								src/accounting/transaction/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										294
									
								
								src/accounting/transaction/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,294 @@ | |||||||
|  | # 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 transaction 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, JournalEntry | ||||||
|  | from accounting.transaction.utils.offset_alias import offset_alias | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from accounting.utils.strip_text import strip_text | ||||||
|  | from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm | ||||||
|  |  | ||||||
|  | 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 SameCurrencyAsOriginalEntries: | ||||||
|  |     """The validator to check if the currency is the same as the original | ||||||
|  |     entries.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, CurrencyForm) | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_entry_id: set[int] = {x.original_entry_id.data | ||||||
|  |                                        for x in form.entries | ||||||
|  |                                        if x.original_entry_id.data is not None} | ||||||
|  |         if len(original_entry_id) == 0: | ||||||
|  |             return | ||||||
|  |         original_entry_currency_codes: set[str] = set(db.session.scalars( | ||||||
|  |             sa.select(JournalEntry.currency_code) | ||||||
|  |             .filter(JournalEntry.id.in_(original_entry_id))).all()) | ||||||
|  |         for currency_code in original_entry_currency_codes: | ||||||
|  |             if field.data != currency_code: | ||||||
|  |                 raise ValidationError(lazy_gettext( | ||||||
|  |                     "The currency must be the same as the original entry.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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_entries: list[JournalEntry] = JournalEntry.query\ | ||||||
|  |             .join(offset, be(JournalEntry.id == offset.c.original_entry_id), | ||||||
|  |                   isouter=True)\ | ||||||
|  |             .filter(JournalEntry.id.in_({x.eid.data for x in form.entries | ||||||
|  |                                          if x.eid.data is not None}))\ | ||||||
|  |             .group_by(JournalEntry.id, JournalEntry.currency_code)\ | ||||||
|  |             .having(sa.func.count(offset.c.id) > 0).all() | ||||||
|  |         for original_entry in original_entries: | ||||||
|  |             if original_entry.currency_code != field.data: | ||||||
|  |                 raise ValidationError(lazy_gettext( | ||||||
|  |                     "The currency must not be changed when there is offset.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NeedSomeJournalEntries: | ||||||
|  |     """The validator to check if there is any journal entry sub-form.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: FieldList) -> None: | ||||||
|  |         if len(field) == 0: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "Please add some journal entries.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IsBalanced: | ||||||
|  |     """The validator to check that the total amount of the debit and credit | ||||||
|  |     entries 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 transaction.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField() | ||||||
|  |     """The currency code.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def entries(self) -> list[JournalEntryForm]: | ||||||
|  |         """Returns the journal entry sub-forms. | ||||||
|  |  | ||||||
|  |         :return: The journal entry sub-forms. | ||||||
|  |         """ | ||||||
|  |         entry_forms: list[JournalEntryForm] = [] | ||||||
|  |         if isinstance(self, IncomeCurrencyForm): | ||||||
|  |             entry_forms.extend([x.form for x in self.credit]) | ||||||
|  |         elif isinstance(self, ExpenseCurrencyForm): | ||||||
|  |             entry_forms.extend([x.form for x in self.debit]) | ||||||
|  |         elif isinstance(self, TransferCurrencyForm): | ||||||
|  |             entry_forms.extend([x.form for x in self.debit]) | ||||||
|  |             entry_forms.extend([x.form for x in self.credit]) | ||||||
|  |         return entry_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 | ||||||
|  |         """ | ||||||
|  |         entry_forms: list[JournalEntryForm] = self.entries | ||||||
|  |         original_entry_id: set[int] \ | ||||||
|  |             = {x.original_entry_id.data for x in entry_forms | ||||||
|  |                if x.original_entry_id.data is not None} | ||||||
|  |         if len(original_entry_id) > 0: | ||||||
|  |             return True | ||||||
|  |         entry_id: set[int] = {x.eid.data for x in entry_forms | ||||||
|  |                               if x.eid.data is not None} | ||||||
|  |         select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\ | ||||||
|  |             .filter(JournalEntry.original_entry_id.in_(entry_id)) | ||||||
|  |         return db.session.scalar(select) > 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IncomeCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a cash income transaction.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[CURRENCY_REQUIRED, | ||||||
|  |                     CurrencyExists(), | ||||||
|  |                     SameCurrencyAsOriginalEntries(), | ||||||
|  |                     KeepCurrencyWhenHavingOffset()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     credit = FieldList(FormField(CreditEntryForm), | ||||||
|  |                        validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The credit entries.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the credit journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the credit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entry 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 ExpenseCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a cash expense transaction.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[CURRENCY_REQUIRED, | ||||||
|  |                     CurrencyExists(), | ||||||
|  |                     SameCurrencyAsOriginalEntries(), | ||||||
|  |                     KeepCurrencyWhenHavingOffset()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     debit = FieldList(FormField(DebitEntryForm), | ||||||
|  |                       validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The debit entries.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the debit journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the debit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entry 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 transaction.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[CURRENCY_REQUIRED, | ||||||
|  |                     CurrencyExists(), | ||||||
|  |                     SameCurrencyAsOriginalEntries(), | ||||||
|  |                     KeepCurrencyWhenHavingOffset()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     debit = FieldList(FormField(DebitEntryForm), | ||||||
|  |                       validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The debit entries.""" | ||||||
|  |     credit = FieldList(FormField(CreditEntryForm), | ||||||
|  |                        validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The credit entries.""" | ||||||
|  |     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 journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the debit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the credit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entry 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 journal entry 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)] | ||||||
							
								
								
									
										524
									
								
								src/accounting/transaction/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										524
									
								
								src/accounting/transaction/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,524 @@ | |||||||
|  | # 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 journal entry sub-forms for the transaction 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, JournalEntry | ||||||
|  | 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 OriginalEntryExists: | ||||||
|  |     """The validator to check if the original entry exists.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if db.session.get(JournalEntry, field.data) is None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The original entry does not exist.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OriginalEntryOppositeSide: | ||||||
|  |     """The validator to check if the original entry is on the opposite side.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_entry: JournalEntry | None \ | ||||||
|  |             = db.session.get(JournalEntry, field.data) | ||||||
|  |         if original_entry is None: | ||||||
|  |             return | ||||||
|  |         if isinstance(form, CreditEntryForm) and original_entry.is_debit: | ||||||
|  |             return | ||||||
|  |         if isinstance(form, DebitEntryForm) and not original_entry.is_debit: | ||||||
|  |             return | ||||||
|  |         raise ValidationError(lazy_gettext( | ||||||
|  |             "The original entry is on the same side.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OriginalEntryNeedOffset: | ||||||
|  |     """The validator to check if the original entry needs offset.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_entry: JournalEntry | None \ | ||||||
|  |             = db.session.get(JournalEntry, field.data) | ||||||
|  |         if original_entry is None: | ||||||
|  |             return | ||||||
|  |         if not original_entry.account.is_need_offset: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The original entry does not need offset.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OriginalEntryNotOffset: | ||||||
|  |     """The validator to check if the original entry is not itself an offset | ||||||
|  |     entry.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_entry: JournalEntry | None \ | ||||||
|  |             = db.session.get(JournalEntry, field.data) | ||||||
|  |         if original_entry is None: | ||||||
|  |             return | ||||||
|  |         if original_entry.original_entry_id is not None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The original entry cannot be an offset entry.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 journal entries.""" | ||||||
|  |  | ||||||
|  |     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 entries.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IsCreditAccount: | ||||||
|  |     """The validator to check if the account is for credit journal entries.""" | ||||||
|  |  | ||||||
|  |     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 entries.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SameAccountAsOriginalEntry: | ||||||
|  |     """The validator to check if the account is the same as the original | ||||||
|  |     entry.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, JournalEntryForm) | ||||||
|  |         if field.data is None or form.original_entry_id.data is None: | ||||||
|  |             return | ||||||
|  |         original_entry: JournalEntry | None \ | ||||||
|  |             = db.session.get(JournalEntry, form.original_entry_id.data) | ||||||
|  |         if original_entry is None: | ||||||
|  |             return | ||||||
|  |         if field.data != original_entry.account_code: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The account must be the same as the original entry.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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, JournalEntryForm) | ||||||
|  |         if field.data is None or form.eid.data is None: | ||||||
|  |             return | ||||||
|  |         entry: JournalEntry | None = db.session.query(JournalEntry)\ | ||||||
|  |             .filter(JournalEntry.id == form.eid.data)\ | ||||||
|  |             .options(selectinload(JournalEntry.offsets)).first() | ||||||
|  |         if entry is None or len(entry.offsets) == 0: | ||||||
|  |             return | ||||||
|  |         if field.data != entry.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 journal entry does not start from | ||||||
|  |     the debit side.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, DebitEntryForm) | ||||||
|  |         if field.data is None \ | ||||||
|  |                 or field.data[0] != "2" \ | ||||||
|  |                 or form.original_entry_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 entry cannot start from the debit side.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NotStartReceivableFromCredit: | ||||||
|  |     """The validator to check that a receivable journal entry does not start | ||||||
|  |     from the credit side.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, CreditEntryForm) | ||||||
|  |         if field.data is None \ | ||||||
|  |                 or field.data[0] != "1" \ | ||||||
|  |                 or form.original_entry_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 entry cannot start from the credit side.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 NotExceedingOriginalEntryNetBalance: | ||||||
|  |     """The validator to check if the amount exceeds the net balance of the | ||||||
|  |     original entry.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: DecimalField) -> None: | ||||||
|  |         assert isinstance(form, JournalEntryForm) | ||||||
|  |         if field.data is None or form.original_entry_id.data is None: | ||||||
|  |             return | ||||||
|  |         original_entry: JournalEntry | None \ | ||||||
|  |             = db.session.get(JournalEntry, form.original_entry_id.data) | ||||||
|  |         if original_entry is None: | ||||||
|  |             return | ||||||
|  |         is_debit: bool = isinstance(form, DebitEntryForm) | ||||||
|  |         existing_entry_id: set[int] = set() | ||||||
|  |         if form.txn_form.obj is not None: | ||||||
|  |             existing_entry_id = {x.id for x in form.txn_form.obj.entries} | ||||||
|  |         offset_total_func: sa.Function = sa.func.sum(sa.case( | ||||||
|  |             (be(JournalEntry.is_debit == is_debit), JournalEntry.amount), | ||||||
|  |             else_=-JournalEntry.amount)) | ||||||
|  |         offset_total_but_form: Decimal | None = db.session.scalar( | ||||||
|  |             sa.select(offset_total_func) | ||||||
|  |             .filter(be(JournalEntry.original_entry_id == original_entry.id), | ||||||
|  |                     JournalEntry.id.not_in(existing_entry_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.txn_form.entries | ||||||
|  |              if x.original_entry_id.data == original_entry.id | ||||||
|  |              and x.amount != field and x.amount.data is not None]) | ||||||
|  |         net_balance: Decimal = original_entry.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 entry.", 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, JournalEntryForm) | ||||||
|  |         if field.data is None or form.eid.data is None: | ||||||
|  |             return | ||||||
|  |         is_debit: bool = isinstance(form, DebitEntryForm) | ||||||
|  |         select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case( | ||||||
|  |             (JournalEntry.is_debit != is_debit, JournalEntry.amount), | ||||||
|  |             else_=-JournalEntry.amount)))\ | ||||||
|  |             .filter(be(JournalEntry.original_entry_id == form.eid.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 JournalEntryForm(FlaskForm): | ||||||
|  |     """The base form to create or edit a journal entry.""" | ||||||
|  |     eid = IntegerField() | ||||||
|  |     """The existing journal entry ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     original_entry_id = IntegerField() | ||||||
|  |     """The Id of the original entry.""" | ||||||
|  |     account_code = StringField() | ||||||
|  |     """The account code.""" | ||||||
|  |     amount = DecimalField() | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         """Constructs a base transaction form. | ||||||
|  |  | ||||||
|  |         :param args: The arguments. | ||||||
|  |         :param kwargs: The keyword arguments. | ||||||
|  |         """ | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         from .transaction import TransactionForm | ||||||
|  |         self.txn_form: TransactionForm | None = None | ||||||
|  |         """The source transaction 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_entry(self) -> JournalEntry | None: | ||||||
|  |         """Returns the original entry. | ||||||
|  |  | ||||||
|  |         :return: The original entry. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "____original_entry"): | ||||||
|  |             def get_entry() -> JournalEntry | None: | ||||||
|  |                 if self.original_entry_id.data is None: | ||||||
|  |                     return None | ||||||
|  |                 return db.session.get(JournalEntry, | ||||||
|  |                                       self.original_entry_id.data) | ||||||
|  |             setattr(self, "____original_entry", get_entry()) | ||||||
|  |         return getattr(self, "____original_entry") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def original_entry_date(self) -> date | None: | ||||||
|  |         """Returns the text representation of the original entry. | ||||||
|  |  | ||||||
|  |         :return: The text representation of the original entry. | ||||||
|  |         """ | ||||||
|  |         return None if self.__original_entry is None \ | ||||||
|  |             else self.__original_entry.transaction.date | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def original_entry_text(self) -> str | None: | ||||||
|  |         """Returns the text representation of the original entry. | ||||||
|  |  | ||||||
|  |         :return: The text representation of the original entry. | ||||||
|  |         """ | ||||||
|  |         return None if self.__original_entry is None \ | ||||||
|  |             else str(self.__original_entry) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_need_offset(self) -> bool: | ||||||
|  |         """Returns whether the entry needs offset. | ||||||
|  |  | ||||||
|  |         :return: True if the entry needs offset, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if self.account_code.data is None: | ||||||
|  |             return False | ||||||
|  |         if self.account_code.data[0] == "1": | ||||||
|  |             if isinstance(self, CreditEntryForm): | ||||||
|  |                 return False | ||||||
|  |         elif self.account_code.data[0] == "2": | ||||||
|  |             if isinstance(self, DebitEntryForm): | ||||||
|  |                 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[JournalEntry]: | ||||||
|  |         """Returns the offsets. | ||||||
|  |  | ||||||
|  |         :return: The offsets. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "__offsets"): | ||||||
|  |             def get_offsets() -> list[JournalEntry]: | ||||||
|  |                 if not self.is_need_offset or self.eid.data is None: | ||||||
|  |                     return [] | ||||||
|  |                 return JournalEntry.query\ | ||||||
|  |                     .filter(JournalEntry.original_entry_id == self.eid.data)\ | ||||||
|  |                     .options(selectinload(JournalEntry.transaction), | ||||||
|  |                              selectinload(JournalEntry.account), | ||||||
|  |                              selectinload(JournalEntry.offsets) | ||||||
|  |                              .selectinload(JournalEntry.transaction)).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.eid.data is None: | ||||||
|  |                     return None | ||||||
|  |                 is_debit: bool = isinstance(self, DebitEntryForm) | ||||||
|  |                 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.eid.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 DebitEntryForm(JournalEntryForm): | ||||||
|  |     """The form to create or edit a debit journal entry.""" | ||||||
|  |     eid = IntegerField() | ||||||
|  |     """The existing journal entry ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     original_entry_id = IntegerField( | ||||||
|  |         validators=[Optional(), | ||||||
|  |                     OriginalEntryExists(), | ||||||
|  |                     OriginalEntryOppositeSide(), | ||||||
|  |                     OriginalEntryNeedOffset(), | ||||||
|  |                     OriginalEntryNotOffset()]) | ||||||
|  |     """The Id of the original entry.""" | ||||||
|  |     account_code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[ACCOUNT_REQUIRED, | ||||||
|  |                     AccountExists(), | ||||||
|  |                     IsDebitAccount(), | ||||||
|  |                     SameAccountAsOriginalEntry(), | ||||||
|  |                     KeepAccountWhenHavingOffset(), | ||||||
|  |                     NotStartPayableFromDebit()]) | ||||||
|  |     """The account code.""" | ||||||
|  |     offset_original_entry_id = IntegerField() | ||||||
|  |     """The Id of the original entry.""" | ||||||
|  |     summary = StringField(filters=[strip_text]) | ||||||
|  |     """The summary.""" | ||||||
|  |     amount = DecimalField( | ||||||
|  |         validators=[PositiveAmount(), | ||||||
|  |                     NotExceedingOriginalEntryNetBalance(), | ||||||
|  |                     NotLessThanOffsetTotal()]) | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     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) | ||||||
|  |         obj.original_entry_id = self.original_entry_id.data | ||||||
|  |         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||||
|  |         obj.summary = self.summary.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 CreditEntryForm(JournalEntryForm): | ||||||
|  |     """The form to create or edit a credit journal entry.""" | ||||||
|  |     eid = IntegerField() | ||||||
|  |     """The existing journal entry ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     original_entry_id = IntegerField( | ||||||
|  |         validators=[Optional(), | ||||||
|  |                     OriginalEntryExists(), | ||||||
|  |                     OriginalEntryOppositeSide(), | ||||||
|  |                     OriginalEntryNeedOffset(), | ||||||
|  |                     OriginalEntryNotOffset()]) | ||||||
|  |     """The Id of the original entry.""" | ||||||
|  |     account_code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[ACCOUNT_REQUIRED, | ||||||
|  |                     AccountExists(), | ||||||
|  |                     IsCreditAccount(), | ||||||
|  |                     SameAccountAsOriginalEntry(), | ||||||
|  |                     KeepAccountWhenHavingOffset(), | ||||||
|  |                     NotStartReceivableFromCredit()]) | ||||||
|  |     """The account code.""" | ||||||
|  |     summary = StringField(filters=[strip_text]) | ||||||
|  |     """The summary.""" | ||||||
|  |     amount = DecimalField( | ||||||
|  |         validators=[PositiveAmount(), | ||||||
|  |                     NotExceedingOriginalEntryNetBalance(), | ||||||
|  |                     NotLessThanOffsetTotal()]) | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     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) | ||||||
|  |         obj.original_entry_id = self.original_entry_id.data | ||||||
|  |         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||||
|  |         obj.summary = self.summary.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 | ||||||
							
								
								
									
										92
									
								
								src/accounting/transaction/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/accounting/transaction/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | |||||||
|  | # 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 transaction management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import request | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Transaction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sort_transactions_in(txn_date: date, exclude: int | None = None) -> None: | ||||||
|  |     """Sorts the transactions under a date after changing the date or deleting | ||||||
|  |     a transaction. | ||||||
|  |  | ||||||
|  |     :param txn_date: The date of the transaction. | ||||||
|  |     :param exclude: The transaction ID to exclude. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     conditions: list[sa.BinaryExpression] = [Transaction.date == txn_date] | ||||||
|  |     if exclude is not None: | ||||||
|  |         conditions.append(Transaction.id != exclude) | ||||||
|  |     transactions: list[Transaction] = Transaction.query\ | ||||||
|  |         .filter(*conditions)\ | ||||||
|  |         .order_by(Transaction.no).all() | ||||||
|  |     for i in range(len(transactions)): | ||||||
|  |         if transactions[i].no != i + 1: | ||||||
|  |             transactions[i].no = i + 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionReorderForm: | ||||||
|  |     """The form to reorder the transactions.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, txn_date: date): | ||||||
|  |         """Constructs the form to reorder the transactions in a day. | ||||||
|  |  | ||||||
|  |         :param txn_date: The date. | ||||||
|  |         """ | ||||||
|  |         self.date: date = txn_date | ||||||
|  |         self.is_modified: bool = False | ||||||
|  |  | ||||||
|  |     def save_order(self) -> None: | ||||||
|  |         """Saves the order of the account. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         transactions: list[Transaction] = Transaction.query\ | ||||||
|  |             .filter(Transaction.date == self.date).all() | ||||||
|  |  | ||||||
|  |         # Collects the specified order. | ||||||
|  |         orders: dict[Transaction, int] = {} | ||||||
|  |         for txn in transactions: | ||||||
|  |             if f"{txn.id}-no" in request.form: | ||||||
|  |                 try: | ||||||
|  |                     orders[txn] = int(request.form[f"{txn.id}-no"]) | ||||||
|  |                 except ValueError: | ||||||
|  |                     pass | ||||||
|  |  | ||||||
|  |         # Missing and invalid orders are appended to the end. | ||||||
|  |         missing: list[Transaction] \ | ||||||
|  |             = [x for x in transactions if x not in orders] | ||||||
|  |         if len(missing) > 0: | ||||||
|  |             next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1 | ||||||
|  |             for txn in missing: | ||||||
|  |                 orders[txn] = next_no | ||||||
|  |  | ||||||
|  |         # Sort by the specified order first, and their original order. | ||||||
|  |         transactions.sort(key=lambda x: (orders[x], x.no)) | ||||||
|  |  | ||||||
|  |         # Update the orders. | ||||||
|  |         with db.session.no_autoflush: | ||||||
|  |             for i in range(len(transactions)): | ||||||
|  |                 if transactions[i].no != i + 1: | ||||||
|  |                     transactions[i].no = i + 1 | ||||||
|  |                     self.is_modified = True | ||||||
| @@ -14,268 +14,95 @@ | |||||||
| #  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 forms for the transaction management. | """The transaction forms for the transaction management. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| from __future__ import annotations | import datetime as dt | ||||||
| 
 |  | ||||||
| import re |  | ||||||
| import typing as t | import typing as t | ||||||
| from abc import ABC, abstractmethod | from abc import ABC, abstractmethod | ||||||
| from datetime import date |  | ||||||
| from decimal import Decimal |  | ||||||
| 
 | 
 | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
| from flask import request |  | ||||||
| from flask_babel import LazyString | from flask_babel import LazyString | ||||||
| from flask_wtf import FlaskForm | from flask_wtf import FlaskForm | ||||||
| from wtforms import DateField, StringField, FieldList, FormField, \ | from wtforms import DateField, FieldList, FormField, TextAreaField, \ | ||||||
|     IntegerField, TextAreaField, DecimalField, BooleanField |     BooleanField | ||||||
| from wtforms.validators import DataRequired, ValidationError | from wtforms.validators import DataRequired, ValidationError | ||||||
| 
 | 
 | ||||||
| from accounting import db | from accounting import db | ||||||
| from accounting.locale import lazy_gettext | from accounting.locale import lazy_gettext | ||||||
| from accounting.models import Transaction, Account, JournalEntry, \ | from accounting.models import Transaction, Account, JournalEntry, \ | ||||||
|     TransactionCurrency, Currency |     TransactionCurrency | ||||||
| from accounting.transaction.summary_editor import SummaryEditor | from accounting.transaction.utils.account_option import AccountOption | ||||||
|  | from accounting.transaction.utils.original_entries import \ | ||||||
|  |     get_selectable_original_entries | ||||||
|  | from accounting.transaction.utils.summary_editor import SummaryEditor | ||||||
| from accounting.utils.random_id import new_id | from accounting.utils.random_id import new_id | ||||||
| from accounting.utils.strip_text import strip_text, strip_multiline_text | from accounting.utils.strip_text import strip_multiline_text | ||||||
| from accounting.utils.user import get_current_user_pk | from accounting.utils.user import get_current_user_pk | ||||||
|  | from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \ | ||||||
|  |     TransferCurrencyForm | ||||||
|  | from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm | ||||||
|  | from .reorder import sort_transactions_in | ||||||
| 
 | 
 | ||||||
| MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.") |  | ||||||
| """The error message when the currency code is empty.""" |  | ||||||
| MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.") |  | ||||||
| """The error message when the account code is empty.""" |  | ||||||
| DATE_REQUIRED: DataRequired = DataRequired( | DATE_REQUIRED: DataRequired = DataRequired( | ||||||
|     lazy_gettext("Please fill in the date.")) |     lazy_gettext("Please fill in the date.")) | ||||||
| """The validator to check if the date is empty.""" | """The validator to check if the date is empty.""" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class NotBeforeOriginalEntries: | ||||||
|  |     """The validator to check if the date is not before the original | ||||||
|  |     entries.""" | ||||||
|  | 
 | ||||||
|  |     def __call__(self, form: FlaskForm, field: DateField) -> None: | ||||||
|  |         assert isinstance(form, TransactionForm) | ||||||
|  |         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 entries.")) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class NotAfterOffsetEntries: | ||||||
|  |     """The validator to check if the date is not after the offset entries.""" | ||||||
|  | 
 | ||||||
|  |     def __call__(self, form: FlaskForm, field: DateField) -> None: | ||||||
|  |         assert isinstance(form, TransactionForm) | ||||||
|  |         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 entries.")) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class NeedSomeCurrencies: | class NeedSomeCurrencies: | ||||||
|     """The validator to check if there is any currency sub-form.""" |     """The validator to check if there is any currency sub-form.""" | ||||||
| 
 | 
 | ||||||
|     def __call__(self, form: CurrencyForm, field: FieldList) \ |     def __call__(self, form: FlaskForm, field: FieldList) -> None: | ||||||
|             -> None: |  | ||||||
|         if len(field) == 0: |         if len(field) == 0: | ||||||
|             raise ValidationError(lazy_gettext( |             raise ValidationError(lazy_gettext("Please add some currencies.")) | ||||||
|                 "Please add some currencies.")) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class CurrencyExists: | class CannotDeleteOriginalEntriesWithOffset: | ||||||
|     """The validator to check if the account exists.""" |     """The validator to check the original entries with offset.""" | ||||||
| 
 | 
 | ||||||
|     def __call__(self, form: FlaskForm, field: StringField) -> None: |     def __call__(self, form: FlaskForm, field: FieldList) -> None: | ||||||
|         if field.data is None: |         assert isinstance(form, TransactionForm) | ||||||
|  |         if form.obj is None: | ||||||
|             return |             return | ||||||
|         if db.session.get(Currency, field.data) is None: |         existing_matched_original_entry_id: set[int] \ | ||||||
|             raise ValidationError(lazy_gettext( |             = {x.id for x in form.obj.entries if len(x.offsets) > 0} | ||||||
|                 "The currency does not exist.")) |         entry_id_in_form: set[int] \ | ||||||
| 
 |             = {x.eid.data for x in form.entries if x.eid.data is not None} | ||||||
| 
 |         for entry_id in existing_matched_original_entry_id: | ||||||
| class NeedSomeJournalEntries: |             if entry_id not in entry_id_in_form: | ||||||
|     """The validator to check if there is any journal entry sub-form.""" |                 raise ValidationError(lazy_gettext( | ||||||
| 
 |                     "Journal entries with offset cannot be deleted.")) | ||||||
|     def __call__(self, form: TransferCurrencyForm, field: FieldList) \ |  | ||||||
|             -> None: |  | ||||||
|         if len(field) == 0: |  | ||||||
|             raise ValidationError(lazy_gettext( |  | ||||||
|                 "Please add some journal entries.")) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 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 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 IsDebitAccount: |  | ||||||
|     """The validator to check if the account is for debit journal entries.""" |  | ||||||
| 
 |  | ||||||
|     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 entries.")) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 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.""" |  | ||||||
| 
 |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         """Returns the string representation of the account option. |  | ||||||
| 
 |  | ||||||
|         :return: The string representation of the account option. |  | ||||||
|         """ |  | ||||||
|         return self.__str |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class JournalEntryForm(FlaskForm): |  | ||||||
|     """The base form to create or edit a journal entry.""" |  | ||||||
|     eid = IntegerField() |  | ||||||
|     """The existing journal entry ID.""" |  | ||||||
|     no = IntegerField() |  | ||||||
|     """The order in the currency.""" |  | ||||||
|     account_code = StringField() |  | ||||||
|     """The account code.""" |  | ||||||
|     amount = DecimalField() |  | ||||||
|     """The amount.""" |  | ||||||
| 
 |  | ||||||
|     @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 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 DebitEntryForm(JournalEntryForm): |  | ||||||
|     """The form to create or edit a debit journal entry.""" |  | ||||||
|     eid = IntegerField() |  | ||||||
|     """The existing journal entry ID.""" |  | ||||||
|     no = IntegerField() |  | ||||||
|     """The order in the currency.""" |  | ||||||
|     account_code = StringField( |  | ||||||
|         filters=[strip_text], |  | ||||||
|         validators=[DataRequired(MISSING_ACCOUNT), |  | ||||||
|                     AccountExists(), |  | ||||||
|                     IsDebitAccount()]) |  | ||||||
|     """The account code.""" |  | ||||||
|     summary = StringField(filters=[strip_text]) |  | ||||||
|     """The summary.""" |  | ||||||
|     amount = DecimalField(validators=[PositiveAmount()]) |  | ||||||
|     """The amount.""" |  | ||||||
| 
 |  | ||||||
|     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) |  | ||||||
|         obj.account_id = Account.find_by_code(self.account_code.data).id |  | ||||||
|         obj.summary = self.summary.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 IsCreditAccount: |  | ||||||
|     """The validator to check if the account is for credit journal entries.""" |  | ||||||
| 
 |  | ||||||
|     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 entries.")) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class CreditEntryForm(JournalEntryForm): |  | ||||||
|     """The form to create or edit a credit journal entry.""" |  | ||||||
|     eid = IntegerField() |  | ||||||
|     """The existing journal entry ID.""" |  | ||||||
|     no = IntegerField() |  | ||||||
|     """The order in the currency.""" |  | ||||||
|     account_code = StringField( |  | ||||||
|         filters=[strip_text], |  | ||||||
|         validators=[DataRequired(MISSING_ACCOUNT), |  | ||||||
|                     AccountExists(), |  | ||||||
|                     IsCreditAccount()]) |  | ||||||
|     """The account code.""" |  | ||||||
|     summary = StringField(filters=[strip_text]) |  | ||||||
|     """The summary.""" |  | ||||||
|     amount = DecimalField(validators=[PositiveAmount()]) |  | ||||||
|     """The amount.""" |  | ||||||
| 
 |  | ||||||
|     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) |  | ||||||
|         obj.account_id = Account.find_by_code(self.account_code.data).id |  | ||||||
|         obj.summary = self.summary.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 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class CurrencyForm(FlaskForm): |  | ||||||
|     """The form to create or edit a currency in a transaction.""" |  | ||||||
|     no = IntegerField() |  | ||||||
|     """The order in the transaction.""" |  | ||||||
|     code = StringField() |  | ||||||
|     """The currency code.""" |  | ||||||
|     whole_form = BooleanField() |  | ||||||
|     """The pseudo field for the whole form validators.""" |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TransactionForm(FlaskForm): | class TransactionForm(FlaskForm): | ||||||
| @@ -300,8 +127,19 @@ class TransactionForm(FlaskForm): | |||||||
|         """The journal entry collector.  The default is the base abstract |         """The journal entry collector.  The default is the base abstract | ||||||
|         collector only to provide the correct type.  The subclass forms should |         collector only to provide the correct type.  The subclass forms should | ||||||
|         provide their own collectors.""" |         provide their own collectors.""" | ||||||
|         self.__in_use_account_id: set[int] | None = None |         self.obj: Transaction | None = kwargs.get("obj") | ||||||
|         """The ID of the accounts that are in use.""" |         """The transaction, when editing an existing one.""" | ||||||
|  |         self._is_need_payable: bool = False | ||||||
|  |         """Whether we need the payable original entries.""" | ||||||
|  |         self._is_need_receivable: bool = False | ||||||
|  |         """Whether we need the receivable original entries.""" | ||||||
|  |         self.__original_entry_options: list[JournalEntry] | None = None | ||||||
|  |         """The options of the original entries.""" | ||||||
|  |         self.__net_balance_exceeded: dict[int, LazyString] | None = None | ||||||
|  |         """The original entries whose net balances were exceeded by the | ||||||
|  |         amounts in the journal entry sub-forms.""" | ||||||
|  |         for entry in self.entries: | ||||||
|  |             entry.txn_form = self | ||||||
| 
 | 
 | ||||||
|     def populate_obj(self, obj: Transaction) -> None: |     def populate_obj(self, obj: Transaction) -> None: | ||||||
|         """Populates the form data into a transaction object. |         """Populates the form data into a transaction object. | ||||||
| @@ -312,6 +150,7 @@ class TransactionForm(FlaskForm): | |||||||
|         is_new: bool = obj.id is None |         is_new: bool = obj.id is None | ||||||
|         if is_new: |         if is_new: | ||||||
|             obj.id = new_id(Transaction) |             obj.id = new_id(Transaction) | ||||||
|  |         self.date: DateField | ||||||
|         self.__set_date(obj, self.date.data) |         self.__set_date(obj, self.date.data) | ||||||
|         obj.note = self.note.data |         obj.note = self.note.data | ||||||
| 
 | 
 | ||||||
| @@ -333,8 +172,18 @@ class TransactionForm(FlaskForm): | |||||||
|             obj.created_by_id = current_user_pk |             obj.created_by_id = current_user_pk | ||||||
|             obj.updated_by_id = current_user_pk |             obj.updated_by_id = current_user_pk | ||||||
| 
 | 
 | ||||||
|     @staticmethod |     @property | ||||||
|     def __set_date(obj: Transaction, new_date: date) -> None: |     def entries(self) -> list[JournalEntryForm]: | ||||||
|  |         """Collects and returns the journal entry sub-forms. | ||||||
|  | 
 | ||||||
|  |         :return: The journal entry sub-forms. | ||||||
|  |         """ | ||||||
|  |         entries: list[JournalEntryForm] = [] | ||||||
|  |         for currency in self.currencies: | ||||||
|  |             entries.extend(currency.entries) | ||||||
|  |         return entries | ||||||
|  | 
 | ||||||
|  |     def __set_date(self, obj: Transaction, new_date: dt.date) -> None: | ||||||
|         """Sets the transaction date and number. |         """Sets the transaction date and number. | ||||||
| 
 | 
 | ||||||
|         :param obj: The transaction object. |         :param obj: The transaction object. | ||||||
| @@ -344,11 +193,23 @@ class TransactionForm(FlaskForm): | |||||||
|         if obj.date is None or obj.date != new_date: |         if obj.date is None or obj.date != new_date: | ||||||
|             if obj.date is not None: |             if obj.date is not None: | ||||||
|                 sort_transactions_in(obj.date, obj.id) |                 sort_transactions_in(obj.date, obj.id) | ||||||
|             sort_transactions_in(new_date, obj.id) |             if self.max_date is not None and new_date == self.max_date: | ||||||
|             count: int = Transaction.query\ |                 db_min_no: int | None = db.session.scalar( | ||||||
|                 .filter(Transaction.date == new_date).count() |                     sa.select(sa.func.min(Transaction.no)) | ||||||
|             obj.date = new_date |                     .filter(Transaction.date == new_date)) | ||||||
|             obj.no = count + 1 |                 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_transactions_in(new_date) | ||||||
|  |             else: | ||||||
|  |                 sort_transactions_in(new_date, obj.id) | ||||||
|  |                 count: int = Transaction.query\ | ||||||
|  |                     .filter(Transaction.date == new_date).count() | ||||||
|  |                 obj.date = new_date | ||||||
|  |                 obj.no = count + 1 | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def debit_account_options(self) -> list[AccountOption]: |     def debit_account_options(self) -> list[AccountOption]: | ||||||
| @@ -357,7 +218,8 @@ class TransactionForm(FlaskForm): | |||||||
|         :return: The selectable debit accounts. |         :return: The selectable debit accounts. | ||||||
|         """ |         """ | ||||||
|         accounts: list[AccountOption] \ |         accounts: list[AccountOption] \ | ||||||
|             = [AccountOption(x) for x in Account.debit()] |             = [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( |         in_use: set[int] = set(db.session.scalars( | ||||||
|             sa.select(JournalEntry.account_id) |             sa.select(JournalEntry.account_id) | ||||||
|             .filter(JournalEntry.is_debit) |             .filter(JournalEntry.is_debit) | ||||||
| @@ -373,7 +235,8 @@ class TransactionForm(FlaskForm): | |||||||
|         :return: The selectable credit accounts. |         :return: The selectable credit accounts. | ||||||
|         """ |         """ | ||||||
|         accounts: list[AccountOption] \ |         accounts: list[AccountOption] \ | ||||||
|             = [AccountOption(x) for x in Account.credit()] |             = [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( |         in_use: set[int] = set(db.session.scalars( | ||||||
|             sa.select(JournalEntry.account_id) |             sa.select(JournalEntry.account_id) | ||||||
|             .filter(sa.not_(JournalEntry.is_debit)) |             .filter(sa.not_(JournalEntry.is_debit)) | ||||||
| @@ -399,6 +262,46 @@ class TransactionForm(FlaskForm): | |||||||
|         """ |         """ | ||||||
|         return SummaryEditor() |         return SummaryEditor() | ||||||
| 
 | 
 | ||||||
|  |     @property | ||||||
|  |     def original_entry_options(self) -> list[JournalEntry]: | ||||||
|  |         """Returns the selectable original entries. | ||||||
|  | 
 | ||||||
|  |         :return: The selectable original entries. | ||||||
|  |         """ | ||||||
|  |         if self.__original_entry_options is None: | ||||||
|  |             self.__original_entry_options = get_selectable_original_entries( | ||||||
|  |                 {x.eid.data for x in self.entries if x.eid.data is not None}, | ||||||
|  |                 self._is_need_payable, self._is_need_receivable) | ||||||
|  |         return self.__original_entry_options | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def min_date(self) -> dt.date | None: | ||||||
|  |         """Returns the minimal available date. | ||||||
|  | 
 | ||||||
|  |         :return: The minimal available date. | ||||||
|  |         """ | ||||||
|  |         original_entry_id: set[int] \ | ||||||
|  |             = {x.original_entry_id.data for x in self.entries | ||||||
|  |                if x.original_entry_id.data is not None} | ||||||
|  |         if len(original_entry_id) == 0: | ||||||
|  |             return None | ||||||
|  |         select: sa.Select = sa.select(sa.func.max(Transaction.date))\ | ||||||
|  |             .join(JournalEntry).filter(JournalEntry.id.in_(original_entry_id)) | ||||||
|  |         return db.session.scalar(select) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def max_date(self) -> dt.date | None: | ||||||
|  |         """Returns the maximum available date. | ||||||
|  | 
 | ||||||
|  |         :return: The maximum available date. | ||||||
|  |         """ | ||||||
|  |         entry_id: set[int] = {x.eid.data for x in self.entries | ||||||
|  |                               if x.eid.data is not None} | ||||||
|  |         select: sa.Select = sa.select(sa.func.min(Transaction.date))\ | ||||||
|  |             .join(JournalEntry)\ | ||||||
|  |             .filter(JournalEntry.original_entry_id.in_(entry_id)) | ||||||
|  |         return db.session.scalar(select) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| T = t.TypeVar("T", bound=TransactionForm) | T = t.TypeVar("T", bound=TransactionForm) | ||||||
| """A transaction form variant.""" | """A transaction form variant.""" | ||||||
| @@ -538,53 +441,25 @@ class JournalEntryCollector(t.Generic[T], ABC): | |||||||
|                                   ord_by_form.get(x))) |                                   ord_by_form.get(x))) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class IncomeCurrencyForm(CurrencyForm): |  | ||||||
|     """The form to create or edit a currency in a cash income transaction.""" |  | ||||||
|     no = IntegerField() |  | ||||||
|     """The order in the transaction.""" |  | ||||||
|     code = StringField( |  | ||||||
|         filters=[strip_text], |  | ||||||
|         validators=[DataRequired(MISSING_CURRENCY), |  | ||||||
|                     CurrencyExists()]) |  | ||||||
|     """The currency code.""" |  | ||||||
|     credit = FieldList(FormField(CreditEntryForm), |  | ||||||
|                        validators=[NeedSomeJournalEntries()]) |  | ||||||
|     """The credit entries.""" |  | ||||||
|     whole_form = BooleanField() |  | ||||||
|     """The pseudo field for the whole form validators.""" |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def credit_total(self) -> Decimal: |  | ||||||
|         """Returns the total amount of the credit journal entries. |  | ||||||
| 
 |  | ||||||
|         :return: The total amount of the credit journal entries. |  | ||||||
|         """ |  | ||||||
|         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 journal entry 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 IncomeTransactionForm(TransactionForm): | class IncomeTransactionForm(TransactionForm): | ||||||
|     """The form to create or edit a cash income transaction.""" |     """The form to create or edit a cash income transaction.""" | ||||||
|     date = DateField(validators=[DATE_REQUIRED]) |     date = DateField( | ||||||
|  |         validators=[DATE_REQUIRED, | ||||||
|  |                     NotBeforeOriginalEntries(), | ||||||
|  |                     NotAfterOffsetEntries()]) | ||||||
|     """The date.""" |     """The date.""" | ||||||
|     currencies = FieldList(FormField(IncomeCurrencyForm), name="currency", |     currencies = FieldList(FormField(IncomeCurrencyForm), name="currency", | ||||||
|                            validators=[NeedSomeCurrencies()]) |                            validators=[NeedSomeCurrencies()]) | ||||||
|     """The journal entries categorized by their currencies.""" |     """The journal entries categorized by their currencies.""" | ||||||
|     note = TextAreaField(filters=[strip_multiline_text]) |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|     """The note.""" |     """The note.""" | ||||||
|  |     whole_form = BooleanField( | ||||||
|  |         validators=[CannotDeleteOriginalEntriesWithOffset()]) | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
| 
 | 
 | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|  |         self._is_need_receivable = True | ||||||
| 
 | 
 | ||||||
|         class Collector(JournalEntryCollector[IncomeTransactionForm]): |         class Collector(JournalEntryCollector[IncomeTransactionForm]): | ||||||
|             """The journal entry collector for the cash income transactions.""" |             """The journal entry collector for the cash income transactions.""" | ||||||
| @@ -611,53 +486,25 @@ class IncomeTransactionForm(TransactionForm): | |||||||
|         self.collector = Collector |         self.collector = Collector | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ExpenseCurrencyForm(CurrencyForm): |  | ||||||
|     """The form to create or edit a currency in a cash expense transaction.""" |  | ||||||
|     no = IntegerField() |  | ||||||
|     """The order in the transaction.""" |  | ||||||
|     code = StringField( |  | ||||||
|         filters=[strip_text], |  | ||||||
|         validators=[DataRequired(MISSING_CURRENCY), |  | ||||||
|                     CurrencyExists()]) |  | ||||||
|     """The currency code.""" |  | ||||||
|     debit = FieldList(FormField(DebitEntryForm), |  | ||||||
|                       validators=[NeedSomeJournalEntries()]) |  | ||||||
|     """The debit entries.""" |  | ||||||
|     whole_form = BooleanField() |  | ||||||
|     """The pseudo field for the whole form validators.""" |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def debit_total(self) -> Decimal: |  | ||||||
|         """Returns the total amount of the debit journal entries. |  | ||||||
| 
 |  | ||||||
|         :return: The total amount of the debit journal entries. |  | ||||||
|         """ |  | ||||||
|         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 journal entry 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 ExpenseTransactionForm(TransactionForm): | class ExpenseTransactionForm(TransactionForm): | ||||||
|     """The form to create or edit a cash expense transaction.""" |     """The form to create or edit a cash expense transaction.""" | ||||||
|     date = DateField(validators=[DATE_REQUIRED]) |     date = DateField( | ||||||
|  |         validators=[DATE_REQUIRED, | ||||||
|  |                     NotBeforeOriginalEntries(), | ||||||
|  |                     NotAfterOffsetEntries()]) | ||||||
|     """The date.""" |     """The date.""" | ||||||
|     currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency", |     currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency", | ||||||
|                            validators=[NeedSomeCurrencies()]) |                            validators=[NeedSomeCurrencies()]) | ||||||
|     """The journal entries categorized by their currencies.""" |     """The journal entries categorized by their currencies.""" | ||||||
|     note = TextAreaField(filters=[strip_multiline_text]) |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|     """The note.""" |     """The note.""" | ||||||
|  |     whole_form = BooleanField( | ||||||
|  |         validators=[CannotDeleteOriginalEntriesWithOffset()]) | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
| 
 | 
 | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|  |         self._is_need_payable = True | ||||||
| 
 | 
 | ||||||
|         class Collector(JournalEntryCollector[ExpenseTransactionForm]): |         class Collector(JournalEntryCollector[ExpenseTransactionForm]): | ||||||
|             """The journal entry collector for the cash expense |             """The journal entry collector for the cash expense | ||||||
| @@ -685,88 +532,26 @@ class ExpenseTransactionForm(TransactionForm): | |||||||
|         self.collector = Collector |         self.collector = Collector | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TransferCurrencyForm(CurrencyForm): |  | ||||||
|     """The form to create or edit a currency in a transfer transaction.""" |  | ||||||
| 
 |  | ||||||
|     class IsBalanced: |  | ||||||
|         """The validator to check that the total amount of the debit and credit |  | ||||||
|         entries are equal.""" |  | ||||||
|         def __call__(self, form: TransferCurrencyForm, field: BooleanField)\ |  | ||||||
|                 -> None: |  | ||||||
|             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.")) |  | ||||||
| 
 |  | ||||||
|     no = IntegerField() |  | ||||||
|     """The order in the transaction.""" |  | ||||||
|     code = StringField( |  | ||||||
|         filters=[strip_text], |  | ||||||
|         validators=[DataRequired(MISSING_CURRENCY), |  | ||||||
|                     CurrencyExists()]) |  | ||||||
|     """The currency code.""" |  | ||||||
|     debit = FieldList(FormField(DebitEntryForm), |  | ||||||
|                       validators=[NeedSomeJournalEntries()]) |  | ||||||
|     """The debit entries.""" |  | ||||||
|     credit = FieldList(FormField(CreditEntryForm), |  | ||||||
|                        validators=[NeedSomeJournalEntries()]) |  | ||||||
|     """The credit entries.""" |  | ||||||
|     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 journal entries. |  | ||||||
| 
 |  | ||||||
|         :return: The total amount of the debit journal entries. |  | ||||||
|         """ |  | ||||||
|         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 journal entries. |  | ||||||
| 
 |  | ||||||
|         :return: The total amount of the credit journal entries. |  | ||||||
|         """ |  | ||||||
|         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 journal entry 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 journal entry 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 TransferTransactionForm(TransactionForm): | class TransferTransactionForm(TransactionForm): | ||||||
|     """The form to create or edit a transfer transaction.""" |     """The form to create or edit a transfer transaction.""" | ||||||
|     date = DateField(validators=[DATE_REQUIRED]) |     date = DateField( | ||||||
|  |         validators=[DATE_REQUIRED, | ||||||
|  |                     NotBeforeOriginalEntries(), | ||||||
|  |                     NotAfterOffsetEntries()]) | ||||||
|     """The date.""" |     """The date.""" | ||||||
|     currencies = FieldList(FormField(TransferCurrencyForm), name="currency", |     currencies = FieldList(FormField(TransferCurrencyForm), name="currency", | ||||||
|                            validators=[NeedSomeCurrencies()]) |                            validators=[NeedSomeCurrencies()]) | ||||||
|     """The journal entries categorized by their currencies.""" |     """The journal entries categorized by their currencies.""" | ||||||
|     note = TextAreaField(filters=[strip_multiline_text]) |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|     """The note.""" |     """The note.""" | ||||||
|  |     whole_form = BooleanField( | ||||||
|  |         validators=[CannotDeleteOriginalEntriesWithOffset()]) | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
| 
 | 
 | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|  |         self._is_need_payable = True | ||||||
|  |         self._is_need_receivable = True | ||||||
| 
 | 
 | ||||||
|         class Collector(JournalEntryCollector[TransferTransactionForm]): |         class Collector(JournalEntryCollector[TransferTransactionForm]): | ||||||
|             """The journal entry collector for the transfer transactions.""" |             """The journal entry collector for the transfer transactions.""" | ||||||
| @@ -795,67 +580,3 @@ class TransferTransactionForm(TransactionForm): | |||||||
|                         self._credit_no = self._credit_no + 1 |                         self._credit_no = self._credit_no + 1 | ||||||
| 
 | 
 | ||||||
|         self.collector = Collector |         self.collector = Collector | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def sort_transactions_in(txn_date: date, exclude: int) -> None: |  | ||||||
|     """Sorts the transactions under a date after changing the date or deleting |  | ||||||
|     a transaction. |  | ||||||
| 
 |  | ||||||
|     :param txn_date: The date of the transaction. |  | ||||||
|     :param exclude: The transaction ID to exclude. |  | ||||||
|     :return: None. |  | ||||||
|     """ |  | ||||||
|     transactions: list[Transaction] = Transaction.query\ |  | ||||||
|         .filter(Transaction.date == txn_date, |  | ||||||
|                 Transaction.id != exclude)\ |  | ||||||
|         .order_by(Transaction.no).all() |  | ||||||
|     for i in range(len(transactions)): |  | ||||||
|         if transactions[i].no != i + 1: |  | ||||||
|             transactions[i].no = i + 1 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class TransactionReorderForm: |  | ||||||
|     """The form to reorder the transactions.""" |  | ||||||
| 
 |  | ||||||
|     def __init__(self, txn_date: date): |  | ||||||
|         """Constructs the form to reorder the transactions in a day. |  | ||||||
| 
 |  | ||||||
|         :param txn_date: The date. |  | ||||||
|         """ |  | ||||||
|         self.date: date = txn_date |  | ||||||
|         self.is_modified: bool = False |  | ||||||
| 
 |  | ||||||
|     def save_order(self) -> None: |  | ||||||
|         """Saves the order of the account. |  | ||||||
| 
 |  | ||||||
|         :return: |  | ||||||
|         """ |  | ||||||
|         transactions: list[Transaction] = Transaction.query\ |  | ||||||
|             .filter(Transaction.date == self.date).all() |  | ||||||
| 
 |  | ||||||
|         # Collects the specified order. |  | ||||||
|         orders: dict[Transaction, int] = {} |  | ||||||
|         for txn in transactions: |  | ||||||
|             if f"{txn.id}-no" in request.form: |  | ||||||
|                 try: |  | ||||||
|                     orders[txn] = int(request.form[f"{txn.id}-no"]) |  | ||||||
|                 except ValueError: |  | ||||||
|                     pass |  | ||||||
| 
 |  | ||||||
|         # Missing and invalid orders are appended to the end. |  | ||||||
|         missing: list[Transaction] \ |  | ||||||
|             = [x for x in transactions if x not in orders] |  | ||||||
|         if len(missing) > 0: |  | ||||||
|             next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1 |  | ||||||
|             for txn in missing: |  | ||||||
|                 orders[txn] = next_no |  | ||||||
| 
 |  | ||||||
|         # Sort by the specified order first, and their original order. |  | ||||||
|         transactions.sort(key=lambda x: (orders[x], x.no)) |  | ||||||
| 
 |  | ||||||
|         # Update the orders. |  | ||||||
|         with db.session.no_autoflush: |  | ||||||
|             for i in range(len(transactions)): |  | ||||||
|                 if transactions[i].no != i + 1: |  | ||||||
|                     transactions[i].no = i + 1 |  | ||||||
|                     self.is_modified = True |  | ||||||
							
								
								
									
										19
									
								
								src/accounting/transaction/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/accounting/transaction/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 transaction management. | ||||||
|  |  | ||||||
|  | """ | ||||||
							
								
								
									
										49
									
								
								src/accounting/transaction/utils/account_option.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/accounting/transaction/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 transaction 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 | ||||||
							
								
								
									
										39
									
								
								src/accounting/transaction/utils/offset_alias.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/accounting/transaction/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 entries. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  |  | ||||||
|  | from accounting.models import JournalEntry | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def offset_alias() -> sa.Alias: | ||||||
|  |     """Returns the SQLAlchemy alias for the offset entries. | ||||||
|  |  | ||||||
|  |     :return: The SQLAlchemy alias for the offset entries. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     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(JournalEntry), name="offset")) | ||||||
| @@ -26,8 +26,8 @@ from flask_wtf import FlaskForm | |||||||
| from accounting.models import Transaction | from accounting.models import Transaction | ||||||
| from accounting.template_globals import default_currency_code | from accounting.template_globals import default_currency_code | ||||||
| from accounting.utils.txn_types import TransactionType | from accounting.utils.txn_types import TransactionType | ||||||
| from .forms import TransactionForm, IncomeTransactionForm, \ | from accounting.transaction.forms import TransactionForm, \ | ||||||
|     ExpenseTransactionForm, TransferTransactionForm |     IncomeTransactionForm, ExpenseTransactionForm, TransferTransactionForm | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TransactionOperator(ABC): | class TransactionOperator(ABC): | ||||||
							
								
								
									
										82
									
								
								src/accounting/transaction/utils/original_entries.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/accounting/transaction/utils/original_entries.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | |||||||
|  | # 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 entries. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask_babel import LazyString | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import Account, Transaction, JournalEntry | ||||||
|  | from accounting.transaction.forms.journal_entry import JournalEntryForm | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from .offset_alias import offset_alias | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_selectable_original_entries( | ||||||
|  |         entry_id_on_form: set[int], is_payable: bool, is_receivable: bool) \ | ||||||
|  |         -> list[JournalEntry]: | ||||||
|  |     """Queries and returns the selectable original entries, with their net | ||||||
|  |     balances.  The offset amounts of the form is excluded. | ||||||
|  |  | ||||||
|  |     :param entry_id_on_form: The ID of the journal entries on the form. | ||||||
|  |     :param is_payable: True to check the payable original entries, or False | ||||||
|  |         otherwise. | ||||||
|  |     :param is_receivable: True to check the receivable original entries, or | ||||||
|  |         False otherwise. | ||||||
|  |     :return: The selectable original entries, with their net balances. | ||||||
|  |     """ | ||||||
|  |     assert is_payable or is_receivable | ||||||
|  |     offset: sa.Alias = offset_alias() | ||||||
|  |     net_balance: sa.Label = (JournalEntry.amount + sa.func.sum(sa.case( | ||||||
|  |         (offset.c.id.in_(entry_id_on_form), 0), | ||||||
|  |         (be(offset.c.is_debit == JournalEntry.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_(JournalEntry.is_debit))) | ||||||
|  |     if is_receivable: | ||||||
|  |         sub_conditions.append(sa.and_(Account.base_code.startswith("1"), | ||||||
|  |                                       JournalEntry.is_debit)) | ||||||
|  |     conditions.append(sa.or_(*sub_conditions)) | ||||||
|  |     select_net_balances: sa.Select = sa.select(JournalEntry.id, net_balance)\ | ||||||
|  |         .join(Account)\ | ||||||
|  |         .join(offset, be(JournalEntry.id == offset.c.original_entry_id), | ||||||
|  |               isouter=True)\ | ||||||
|  |         .filter(*conditions)\ | ||||||
|  |         .group_by(JournalEntry.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()} | ||||||
|  |     entries: list[JournalEntry] = JournalEntry.query\ | ||||||
|  |         .filter(JournalEntry.id.in_({x for x in net_balances}))\ | ||||||
|  |         .join(Transaction)\ | ||||||
|  |         .order_by(Transaction.date, JournalEntry.is_debit, JournalEntry.no)\ | ||||||
|  |         .options(selectinload(JournalEntry.currency), | ||||||
|  |                  selectinload(JournalEntry.account), | ||||||
|  |                  selectinload(JournalEntry.transaction)).all() | ||||||
|  |     for entry in entries: | ||||||
|  |         entry.net_balance = entry.amount if net_balances[entry.id] is None \ | ||||||
|  |             else net_balances[entry.id] | ||||||
|  |     return entries | ||||||
| @@ -218,7 +218,8 @@ class SummaryEditor: | |||||||
|                                       JournalEntry.account_id, |                                       JournalEntry.account_id, | ||||||
|                                       sa.func.count().label("freq"))\ |                                       sa.func.count().label("freq"))\ | ||||||
|             .filter(JournalEntry.summary.is_not(None), |             .filter(JournalEntry.summary.is_not(None), | ||||||
|                     JournalEntry.summary.like("_%—_%"))\ |                     JournalEntry.summary.like("_%—_%"), | ||||||
|  |                     JournalEntry.original_entry_id.is_(None))\ | ||||||
|             .group_by(entry_type, tag_type, tag, JournalEntry.account_id) |             .group_by(entry_type, tag_type, tag, JournalEntry.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] \ | ||||||
| @@ -28,15 +28,16 @@ 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 Transaction | from accounting.models import Transaction | ||||||
|  | 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.permission import has_permission, can_view, can_edit | from accounting.utils.permission import has_permission, can_view, can_edit | ||||||
| from accounting.utils.txn_types import TransactionType | from accounting.utils.txn_types import TransactionType | ||||||
| from accounting.utils.user import get_current_user_pk | from accounting.utils.user import get_current_user_pk | ||||||
| from .forms import sort_transactions_in, TransactionReorderForm | from .forms import sort_transactions_in, TransactionReorderForm | ||||||
| from .operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op |  | ||||||
| from .template_filters import with_type, to_transfer, format_amount_input, \ | from .template_filters import with_type, to_transfer, format_amount_input, \ | ||||||
|     text2html |     text2html | ||||||
|  | from .utils.operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op | ||||||
|  |  | ||||||
| bp: Blueprint = Blueprint("transaction", __name__) | bp: Blueprint = Blueprint("transaction", __name__) | ||||||
| """The view blueprint for the transaction management.""" | """The view blueprint for the transaction management.""" | ||||||
| @@ -87,7 +88,7 @@ def add_transaction(txn_type: TransactionType) -> redirect: | |||||||
|     form.populate_obj(txn) |     form.populate_obj(txn) | ||||||
|     db.session.add(txn) |     db.session.add(txn) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The transaction is added successfully"), "success") |     flash(s(lazy_gettext("The transaction is added successfully")), "success") | ||||||
|     return redirect(inherit_next(__get_detail_uri(txn))) |     return redirect(inherit_next(__get_detail_uri(txn))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -116,6 +117,7 @@ def show_transaction_edit_form(txn: Transaction) -> str: | |||||||
|     if "form" in session: |     if "form" in session: | ||||||
|         form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"]))) |         form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|         del session["form"] |         del session["form"] | ||||||
|  |         form.obj = txn | ||||||
|         form.validate() |         form.validate() | ||||||
|     else: |     else: | ||||||
|         form = txn_op.form(obj=txn) |         form = txn_op.form(obj=txn) | ||||||
| @@ -133,6 +135,7 @@ def update_transaction(txn: Transaction) -> redirect: | |||||||
|     """ |     """ | ||||||
|     txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True) |     txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True) | ||||||
|     form: txn_op.form = txn_op.form(request.form) |     form: txn_op.form = txn_op.form(request.form) | ||||||
|  |     form.obj = txn | ||||||
|     if not form.validate(): |     if not form.validate(): | ||||||
|         flash_form_errors(form) |         flash_form_errors(form) | ||||||
|         session["form"] = urlencode(list(request.form.items())) |         session["form"] = urlencode(list(request.form.items())) | ||||||
| @@ -141,12 +144,13 @@ def update_transaction(txn: Transaction) -> redirect: | |||||||
|     with db.session.no_autoflush: |     with db.session.no_autoflush: | ||||||
|         form.populate_obj(txn) |         form.populate_obj(txn) | ||||||
|     if not form.is_modified: |     if not form.is_modified: | ||||||
|         flash(lazy_gettext("The transaction was not modified."), "success") |         flash(s(lazy_gettext("The transaction was not modified.")), "success") | ||||||
|         return redirect(inherit_next(__get_detail_uri(txn))) |         return redirect(inherit_next(__get_detail_uri(txn))) | ||||||
|     txn.updated_by_id = get_current_user_pk() |     txn.updated_by_id = get_current_user_pk() | ||||||
|     txn.updated_at = sa.func.now() |     txn.updated_at = sa.func.now() | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The transaction is updated successfully."), "success") |     flash(s(lazy_gettext("The transaction is updated successfully.")), | ||||||
|  |           "success") | ||||||
|     return redirect(inherit_next(__get_detail_uri(txn))) |     return redirect(inherit_next(__get_detail_uri(txn))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -162,7 +166,8 @@ def delete_transaction(txn: Transaction) -> redirect: | |||||||
|     txn.delete() |     txn.delete() | ||||||
|     sort_transactions_in(txn.date, txn.id) |     sort_transactions_in(txn.date, txn.id) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The transaction is deleted successfully."), "success") |     flash(s(lazy_gettext("The transaction is deleted successfully.")), | ||||||
|  |           "success") | ||||||
|     return redirect(or_next(__get_default_page_uri())) |     return redirect(or_next(__get_default_page_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -193,10 +198,10 @@ def sort_transactions(txn_date: date) -> redirect: | |||||||
|     form: TransactionReorderForm = TransactionReorderForm(txn_date) |     form: TransactionReorderForm = TransactionReorderForm(txn_date) | ||||||
|     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_default_page_uri())) |         return redirect(or_next(__get_default_page_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_default_page_uri())) |     return redirect(or_next(__get_default_page_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										44
									
								
								src/accounting/utils/cast.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/accounting/utils/cast.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | # 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 utility to cast a SQLAlchemy column into the column type, to avoid | ||||||
|  | warnings from the IDE. | ||||||
|  |  | ||||||
|  | This module should not import any other module from the application. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def be(expression: t.Any) -> sa.BinaryExpression: | ||||||
|  |     """Casts the SQLAlchemy binary expression to the binary expression type. | ||||||
|  |  | ||||||
|  |     :param expression: The binary expression. | ||||||
|  |     :return: The binary expression itself. | ||||||
|  |     """ | ||||||
|  |     assert isinstance(expression, sa.BinaryExpression) | ||||||
|  |     return expression | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def s(message: t.Any) -> str: | ||||||
|  |     """Casts the LazyString message to the string type. | ||||||
|  |  | ||||||
|  |     :param message: The message. | ||||||
|  |     :return: The binary expression itself. | ||||||
|  |     """ | ||||||
|  |     return message | ||||||
| @@ -23,7 +23,7 @@ import typing as t | |||||||
|  |  | ||||||
| from flask import abort, Blueprint | from flask import abort, Blueprint | ||||||
|  |  | ||||||
| from accounting.utils.user import get_current_user | from accounting.utils.user import get_current_user, UserUtilityInterface | ||||||
|  |  | ||||||
|  |  | ||||||
| def has_permission(rule: t.Callable[[], bool]) -> t.Callable: | def has_permission(rule: t.Callable[[], bool]) -> t.Callable: | ||||||
| @@ -87,22 +87,15 @@ def can_edit() -> bool: | |||||||
|     return __can_edit_func() |     return __can_edit_func() | ||||||
|  |  | ||||||
|  |  | ||||||
| def init_app(bp: Blueprint, | def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None: | ||||||
|              can_view_func: t.Callable[[], bool] | None = None, |  | ||||||
|              can_edit_func: t.Callable[[], bool] | None = None) -> None: |  | ||||||
|     """Initializes the application. |     """Initializes the application. | ||||||
|  |  | ||||||
|     :param bp: The blueprint of the accounting application. |     :param bp: The blueprint of the accounting application. | ||||||
|     :param can_view_func: A callback that returns whether the current user can |     :param user_utils: The user utilities. | ||||||
|         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. | ||||||
|     """ |     """ | ||||||
|     global __can_view_func, __can_edit_func |     global __can_view_func, __can_edit_func | ||||||
|     if can_view_func is not None: |     __can_view_func = user_utils.can_view | ||||||
|         __can_view_func = can_view_func |     __can_edit_func = user_utils.can_edit | ||||||
|     if can_edit_func is not None: |     bp.add_app_template_global(user_utils.can_view, "accounting_can_view") | ||||||
|         __can_edit_func = can_edit_func |     bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit") | ||||||
|     bp.add_app_template_global(can_view, "accounting_can_view") |  | ||||||
|     bp.add_app_template_global(can_edit, "accounting_can_edit") |  | ||||||
|   | |||||||
| @@ -29,15 +29,33 @@ from flask_sqlalchemy.model import Model | |||||||
| T = t.TypeVar("T", bound=Model) | T = t.TypeVar("T", bound=Model) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AbstractUserUtils(t.Generic[T], ABC): | class UserUtilityInterface(t.Generic[T], ABC): | ||||||
|     """The abstract user utilities.""" |     """The interface for the user utilities.""" | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def can_view(self) -> bool: | ||||||
|  |         """Returns whether the currently logged-in user can view the accounting | ||||||
|  |         data. | ||||||
|  |  | ||||||
|  |         :return: True if the currently logged-in user can view the accounting | ||||||
|  |             data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def can_edit(self) -> bool: | ||||||
|  |         """Returns whether the currently logged-in user can edit the accounting | ||||||
|  |         data. | ||||||
|  |  | ||||||
|  |         :return: True if the currently logged-in user can edit the accounting | ||||||
|  |             data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     @abstractmethod |     @abstractmethod | ||||||
|     def cls(self) -> t.Type[T]: |     def cls(self) -> t.Type[T]: | ||||||
|         """Returns the user class. |         """Returns the class of the user data model. | ||||||
|  |  | ||||||
|         :return: The user class. |         :return: The class of the user data model. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @@ -66,13 +84,13 @@ class AbstractUserUtils(t.Generic[T], ABC): | |||||||
|  |  | ||||||
|     @abstractmethod |     @abstractmethod | ||||||
|     def get_pk(self, user: T) -> int: |     def get_pk(self, user: T) -> int: | ||||||
|         """Returns the primary key of the user. |         """Returns the primary key of the user, as an integer. | ||||||
|  |  | ||||||
|         :return: The primary key of the user. |         :return: The primary key of the user, as an integer. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|  |  | ||||||
| __user_utils: AbstractUserUtils | __user_utils: UserUtilityInterface | ||||||
| """The user utilities.""" | """The user utilities.""" | ||||||
| user_cls: t.Type[Model] = Model | user_cls: t.Type[Model] = Model | ||||||
| """The user class.""" | """The user class.""" | ||||||
| @@ -80,7 +98,7 @@ user_pk_column: sa.Column = sa.Column(sa.Integer) | |||||||
| """The primary key column of the user class.""" | """The primary key column of the user class.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def init_user_utils(utils: AbstractUserUtils) -> None: | def init_user_utils(utils: UserUtilityInterface) -> None: | ||||||
|     """Initializes the user utilities. |     """Initializes the user utilities. | ||||||
|  |  | ||||||
|     :param utils: The user utilities. |     :param utils: The user utilities. | ||||||
|   | |||||||
| @@ -26,8 +26,8 @@ from click.testing import Result | |||||||
| from flask import Flask | from flask import Flask | ||||||
| from flask.testing import FlaskCliRunner | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
| from test_site import create_app, db | from test_site import db | ||||||
| from testlib import get_client, set_locale | from testlib import create_test_app, get_client, set_locale | ||||||
|  |  | ||||||
| NEXT_URI: str = "/_next" | NEXT_URI: str = "/_next" | ||||||
| """The next URI.""" | """The next URI.""" | ||||||
| @@ -74,7 +74,7 @@ class AccountCommandTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -127,7 +127,7 @@ class AccountTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -372,6 +372,15 @@ class AccountTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], create_uri) |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # A nominal account that needs offset | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": "6172", | ||||||
|  |                                           "title": stock.title, | ||||||
|  |                                           "is_need_offset": "yes"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|         # Success, with spaces to be stripped |         # Success, with spaces to be stripped | ||||||
|         response = self.client.post(store_uri, |         response = self.client.post(store_uri, | ||||||
|                                     data={"csrf_token": self.csrf_token, |                                     data={"csrf_token": self.csrf_token, | ||||||
| @@ -470,6 +479,15 @@ class AccountTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], edit_uri) |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # A nominal account that needs offset | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": "6172", | ||||||
|  |                                           "title": stock.title, | ||||||
|  |                                           "is_need_offset": "yes"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|         # Change the base account |         # Change the base account | ||||||
|         response = self.client.post(update_uri, |         response = self.client.post(update_uri, | ||||||
|                                     data={"csrf_token": self.csrf_token, |                                     data={"csrf_token": self.csrf_token, | ||||||
|   | |||||||
| @@ -26,8 +26,7 @@ from click.testing import Result | |||||||
| from flask import Flask | from flask import Flask | ||||||
| from flask.testing import FlaskCliRunner | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
| from test_site import create_app | from testlib import create_test_app, get_client | ||||||
| from testlib import get_client |  | ||||||
|  |  | ||||||
| LIST_URI: str = "/accounting/base-accounts" | LIST_URI: str = "/accounting/base-accounts" | ||||||
| """The list URI.""" | """The list URI.""" | ||||||
| @@ -45,7 +44,7 @@ class BaseAccountCommandTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import BaseAccount, BaseAccountL10n |         from accounting.models import BaseAccount, BaseAccountL10n | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -98,7 +97,7 @@ class BaseAccountTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import BaseAccount |         from accounting.models import BaseAccount | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|   | |||||||
| @@ -27,8 +27,8 @@ from click.testing import Result | |||||||
| from flask import Flask | from flask import Flask | ||||||
| from flask.testing import FlaskCliRunner | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
| from test_site import create_app, db | from test_site import db | ||||||
| from testlib import get_client, set_locale | from testlib import create_test_app, get_client, set_locale | ||||||
|  |  | ||||||
|  |  | ||||||
| class CurrencyData: | class CurrencyData: | ||||||
| @@ -67,7 +67,7 @@ class CurrencyCommandTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -123,7 +123,7 @@ class CurrencyTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|   | |||||||
							
								
								
									
										685
									
								
								tests/test_offset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										685
									
								
								tests/test_offset.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,685 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The test for the offset. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import unittest | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import httpx | ||||||
|  | from click.testing import Result | ||||||
|  | from flask import Flask | ||||||
|  | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
|  | from test_site import db | ||||||
|  | from testlib import create_test_app, get_client | ||||||
|  | from testlib_offset import TestData, JournalEntryData, TransactionData, \ | ||||||
|  |     CurrencyData | ||||||
|  | from testlib_txn import Accounts, match_txn_detail | ||||||
|  |  | ||||||
|  | PREFIX: str = "/accounting/transactions" | ||||||
|  | """The URL prefix for the transaction management.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OffsetTestCase(unittest.TestCase): | ||||||
|  |     """The offset test case.""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         """Sets up the test. | ||||||
|  |         This is run once per test. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             from accounting.models import BaseAccount, Transaction, \ | ||||||
|  |                 JournalEntry | ||||||
|  |             result: Result | ||||||
|  |             result = runner.invoke(args="init-db") | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             if BaseAccount.query.first() is None: | ||||||
|  |                 result = runner.invoke(args="accounting-init-base") | ||||||
|  |                 self.assertEqual(result.exit_code, 0) | ||||||
|  |             result = runner.invoke(args=["accounting-init-currencies", | ||||||
|  |                                          "-u", "editor"]) | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             result = runner.invoke(args=["accounting-init-accounts", | ||||||
|  |                                          "-u", "editor"]) | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             Transaction.query.delete() | ||||||
|  |             JournalEntry.query.delete() | ||||||
|  |  | ||||||
|  |         self.client, self.csrf_token = get_client(self.app, "editor") | ||||||
|  |         self.data: TestData = TestData(self.app, self.client, self.csrf_token) | ||||||
|  |  | ||||||
|  |     def test_add_receivable_offset(self) -> None: | ||||||
|  |         """Tests to add the receivable offset. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account, Transaction | ||||||
|  |         create_uri: str = f"{PREFIX}/create/income?next=%2F_next" | ||||||
|  |         store_uri: str = f"{PREFIX}/store/income" | ||||||
|  |         form: dict[str, str] | ||||||
|  |         old_amount: Decimal | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         txn_data: TransactionData = TransactionData( | ||||||
|  |             self.data.e_r_or3d.txn.days, [CurrencyData( | ||||||
|  |                 "USD", | ||||||
|  |                 [], | ||||||
|  |                 [JournalEntryData(Accounts.RECEIVABLE, | ||||||
|  |                                   self.data.e_r_or1d.summary, "300", | ||||||
|  |                                   original_entry=self.data.e_r_or1d), | ||||||
|  |                  JournalEntryData(Accounts.RECEIVABLE, | ||||||
|  |                                   self.data.e_r_or1d.summary, "100", | ||||||
|  |                                   original_entry=self.data.e_r_or1d), | ||||||
|  |                  JournalEntryData(Accounts.RECEIVABLE, | ||||||
|  |                                   self.data.e_r_or3d.summary, "100", | ||||||
|  |                                   original_entry=self.data.e_r_or3d)])]) | ||||||
|  |  | ||||||
|  |         # Non-existing original entry ID | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-original_entry_id"] = "9999" | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # The same side | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id | ||||||
|  |         form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account | ||||||
|  |         form["currency-1-credit-1-amount"] = "100" | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # The original entry does not need offset | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.RECEIVABLE) | ||||||
|  |             account.is_need_offset = False | ||||||
|  |             db.session.commit() | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data=txn_data.new_form(self.csrf_token)) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.RECEIVABLE) | ||||||
|  |             account.is_need_offset = True | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         # The original entry is also an offset | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id | ||||||
|  |         form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not the same currency | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-code"] = "EUR" | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not the same account | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - partially offset | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[0].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - unmatched | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-3-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[2].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not before the original entries | ||||||
|  |         old_days = txn_data.days | ||||||
|  |         txn_data.days = old_days + 1 | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |         txn_data.days = old_days | ||||||
|  |  | ||||||
|  |         # Success | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         txn_id: int = match_txn_detail(response.headers["Location"]) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             txn = db.session.get(Transaction, txn_id) | ||||||
|  |             for offset in txn.currencies[0].credit: | ||||||
|  |                 self.assertIsNotNone(offset.original_entry_id) | ||||||
|  |  | ||||||
|  |     def test_edit_receivable_offset(self) -> None: | ||||||
|  |         """Tests to edit the receivable offset. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         txn_data: TransactionData = self.data.t_r_of2 | ||||||
|  |         edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" | ||||||
|  |         update_uri: str = f"{PREFIX}/{txn_data.id}/update" | ||||||
|  |         form: dict[str, str] | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         txn_data.days = self.data.t_r_or2.days | ||||||
|  |         txn_data.currencies[0].debit[0].amount = Decimal("600") | ||||||
|  |         txn_data.currencies[0].credit[0].amount = Decimal("600") | ||||||
|  |         txn_data.currencies[0].debit[2].amount = Decimal("600") | ||||||
|  |         txn_data.currencies[0].credit[2].amount = Decimal("600") | ||||||
|  |  | ||||||
|  |         # Non-existing original entry ID | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-original_entry_id"] = "9999" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # The same side | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id | ||||||
|  |         form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account | ||||||
|  |         form["currency-1-debit-1-amount"] = "100" | ||||||
|  |         form["currency-1-credit-1-amount"] = "100" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # The original entry does not need offset | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.RECEIVABLE) | ||||||
|  |             account.is_need_offset = False | ||||||
|  |             db.session.commit() | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data=txn_data.update_form(self.csrf_token)) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.RECEIVABLE) | ||||||
|  |             account.is_need_offset = True | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         # The original entry is also an offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id | ||||||
|  |         form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not the same currency | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-code"] = "EUR" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not the same account | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - partially offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[0].amount + Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[0].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - unmatched | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-3-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[2].amount + Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-3-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[2].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not before the original entries | ||||||
|  |         old_days: int = txn_data.days | ||||||
|  |         txn_data.days = old_days + 1 | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |         txn_data.days = old_days | ||||||
|  |  | ||||||
|  |         # Success | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{txn_data.id}?next=%2F_next") | ||||||
|  |  | ||||||
|  |     def test_edit_receivable_original_entry(self) -> None: | ||||||
|  |         """Tests to edit the receivable original entry. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Transaction | ||||||
|  |         txn_data: TransactionData = self.data.t_r_or1 | ||||||
|  |         edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" | ||||||
|  |         update_uri: str = f"{PREFIX}/{txn_data.id}/update" | ||||||
|  |         form: dict[str, str] | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         txn_data.days = self.data.t_r_of1.days | ||||||
|  |         txn_data.currencies[0].debit[0].amount = Decimal("800") | ||||||
|  |         txn_data.currencies[0].credit[0].amount = Decimal("800") | ||||||
|  |         txn_data.currencies[0].debit[1].amount = Decimal("3.4") | ||||||
|  |         txn_data.currencies[0].credit[1].amount = Decimal("3.4") | ||||||
|  |  | ||||||
|  |         # Not the same currency | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-code"] = "EUR" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not the same account | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not less than offset total - partially offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[0].amount - Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[0].amount - Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not less than offset total - fully offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-2-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[1].amount - Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-2-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[1].amount - Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not after the offset entries | ||||||
|  |         old_days: int = txn_data.days | ||||||
|  |         txn_data.days = old_days - 1 | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |         txn_data.days = old_days | ||||||
|  |  | ||||||
|  |         # Not deleting matched original entries | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         del form["currency-1-debit-1-eid"] | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Success | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{txn_data.id}?next=%2F_next") | ||||||
|  |  | ||||||
|  |         # The original entry is always before the offset entry, even when they | ||||||
|  |         # happen in the same day. | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             txn_or: Transaction | None = db.session.get( | ||||||
|  |                 Transaction, txn_data.id) | ||||||
|  |             self.assertIsNotNone(txn_or) | ||||||
|  |             txn_of: Transaction | None = db.session.get( | ||||||
|  |                 Transaction, self.data.t_r_of1.id) | ||||||
|  |             self.assertIsNotNone(txn_of) | ||||||
|  |             self.assertEqual(txn_or.date, txn_of.date) | ||||||
|  |             self.assertLess(txn_or.no, txn_of.no) | ||||||
|  |  | ||||||
|  |     def test_add_payable_offset(self) -> None: | ||||||
|  |         """Tests to add the payable offset. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account, Transaction | ||||||
|  |         create_uri: str = f"{PREFIX}/create/expense?next=%2F_next" | ||||||
|  |         store_uri: str = f"{PREFIX}/store/expense" | ||||||
|  |         form: dict[str, str] | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         txn_data: TransactionData = TransactionData( | ||||||
|  |             self.data.e_p_or3c.txn.days, [CurrencyData( | ||||||
|  |                 "USD", | ||||||
|  |                 [JournalEntryData(Accounts.PAYABLE, | ||||||
|  |                                   self.data.e_p_or1c.summary, "500", | ||||||
|  |                                   original_entry=self.data.e_p_or1c), | ||||||
|  |                  JournalEntryData(Accounts.PAYABLE, | ||||||
|  |                                   self.data.e_p_or1c.summary, "300", | ||||||
|  |                                   original_entry=self.data.e_p_or1c), | ||||||
|  |                  JournalEntryData(Accounts.PAYABLE, | ||||||
|  |                                   self.data.e_p_or3c.summary, "120", | ||||||
|  |                                   original_entry=self.data.e_p_or3c)], | ||||||
|  |                 [])]) | ||||||
|  |  | ||||||
|  |         # Non-existing original entry ID | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-original_entry_id"] = "9999" | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # The same side | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id | ||||||
|  |         form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account | ||||||
|  |         form["currency-1-debit-1-amount"] = "100" | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # The original entry does not need offset | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.PAYABLE) | ||||||
|  |             account.is_need_offset = False | ||||||
|  |             db.session.commit() | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data=txn_data.new_form(self.csrf_token)) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.PAYABLE) | ||||||
|  |             account.is_need_offset = True | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         # The original entry is also an offset | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id | ||||||
|  |         form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not the same currency | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-code"] = "EUR" | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not the same account | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - partially offset | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[0].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - unmatched | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-3-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[2].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Not before the original entries | ||||||
|  |         old_days: int = txn_data.days | ||||||
|  |         txn_data.days = old_days + 1 | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |         txn_data.days = old_days | ||||||
|  |  | ||||||
|  |         # Success | ||||||
|  |         form = txn_data.new_form(self.csrf_token) | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         txn_id: int = match_txn_detail(response.headers["Location"]) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             txn = db.session.get(Transaction, txn_id) | ||||||
|  |             for offset in txn.currencies[0].debit: | ||||||
|  |                 self.assertIsNotNone(offset.original_entry_id) | ||||||
|  |  | ||||||
|  |     def test_edit_payable_offset(self) -> None: | ||||||
|  |         """Tests to edit the payable offset. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account, Transaction | ||||||
|  |         txn_data: TransactionData = self.data.t_p_of2 | ||||||
|  |         edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" | ||||||
|  |         update_uri: str = f"{PREFIX}/{txn_data.id}/update" | ||||||
|  |         form: dict[str, str] | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         txn_data.days = self.data.t_p_or2.days | ||||||
|  |         txn_data.currencies[0].debit[0].amount = Decimal("1100") | ||||||
|  |         txn_data.currencies[0].credit[0].amount = Decimal("1100") | ||||||
|  |         txn_data.currencies[0].debit[2].amount = Decimal("900") | ||||||
|  |         txn_data.currencies[0].credit[2].amount = Decimal("900") | ||||||
|  |  | ||||||
|  |         # Non-existing original entry ID | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-original_entry_id"] = "9999" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # The same side | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id | ||||||
|  |         form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account | ||||||
|  |         form["currency-1-debit-1-amount"] = "100" | ||||||
|  |         form["currency-1-credit-1-amount"] = "100" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # The original entry does not need offset | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.PAYABLE) | ||||||
|  |             account.is_need_offset = False | ||||||
|  |             db.session.commit() | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data=txn_data.update_form(self.csrf_token)) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account = Account.find_by_code(Accounts.PAYABLE) | ||||||
|  |             account.is_need_offset = True | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         # The original entry is also an offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id | ||||||
|  |         form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not the same currency | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-code"] = "EUR" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not the same account | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - partially offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[0].amount + Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[0].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not exceeding net balance - unmatched | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-3-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[2].amount + Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-3-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[2].amount + Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not before the original entries | ||||||
|  |         old_days: int = txn_data.days | ||||||
|  |         txn_data.days = old_days + 1 | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |         txn_data.days = old_days | ||||||
|  |  | ||||||
|  |         # Success | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         txn_id: int = match_txn_detail(response.headers["Location"]) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             txn = db.session.get(Transaction, txn_id) | ||||||
|  |             for offset in txn.currencies[0].debit: | ||||||
|  |                 self.assertIsNotNone(offset.original_entry_id) | ||||||
|  |  | ||||||
|  |     def test_edit_payable_original_entry(self) -> None: | ||||||
|  |         """Tests to edit the payable original entry. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Transaction | ||||||
|  |         txn_data: TransactionData = self.data.t_p_or1 | ||||||
|  |         edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" | ||||||
|  |         update_uri: str = f"{PREFIX}/{txn_data.id}/update" | ||||||
|  |         form: dict[str, str] | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         txn_data.days = self.data.t_p_of1.days | ||||||
|  |         txn_data.currencies[0].debit[0].amount = Decimal("1200") | ||||||
|  |         txn_data.currencies[0].credit[0].amount = Decimal("1200") | ||||||
|  |         txn_data.currencies[0].debit[1].amount = Decimal("0.9") | ||||||
|  |         txn_data.currencies[0].credit[1].amount = Decimal("0.9") | ||||||
|  |  | ||||||
|  |         # Not the same currency | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-code"] = "EUR" | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not the same account | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not less than offset total - partially offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[0].amount - Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-1-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[0].amount - Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not less than offset total - fully offset | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         form["currency-1-debit-2-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].debit[1].amount - Decimal("0.01")) | ||||||
|  |         form["currency-1-credit-2-amount"] \ | ||||||
|  |             = str(txn_data.currencies[0].credit[1].amount - Decimal("0.01")) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Not after the offset entries | ||||||
|  |         old_days: int = txn_data.days | ||||||
|  |         txn_data.days = old_days - 1 | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |         txn_data.days = old_days | ||||||
|  |  | ||||||
|  |         # Not deleting matched original entries | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         del form["currency-1-credit-1-eid"] | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Success | ||||||
|  |         form = txn_data.update_form(self.csrf_token) | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{txn_data.id}?next=%2F_next") | ||||||
|  |  | ||||||
|  |         # The original entry is always before the offset entry, even when they | ||||||
|  |         # happen in the same day | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             txn_or: Transaction | None = db.session.get( | ||||||
|  |                 Transaction, txn_data.id) | ||||||
|  |             self.assertIsNotNone(txn_or) | ||||||
|  |             txn_of: Transaction | None = db.session.get( | ||||||
|  |                 Transaction, self.data.t_p_of1.id) | ||||||
|  |             self.assertIsNotNone(txn_of) | ||||||
|  |             self.assertEqual(txn_or.date, txn_of.date) | ||||||
|  |             self.assertLess(txn_or.no, txn_of.no) | ||||||
| @@ -29,8 +29,6 @@ from flask_sqlalchemy import SQLAlchemy | |||||||
| from flask_wtf import CSRFProtect | from flask_wtf import CSRFProtect | ||||||
| from sqlalchemy import Column | from sqlalchemy import Column | ||||||
|  |  | ||||||
| import accounting.utils.user |  | ||||||
|  |  | ||||||
| bp: Blueprint = Blueprint("home", __name__) | bp: Blueprint = Blueprint("home", __name__) | ||||||
| babel_js: BabelJS = BabelJS() | babel_js: BabelJS = BabelJS() | ||||||
| csrf: CSRFProtect = CSRFProtect() | csrf: CSRFProtect = CSRFProtect() | ||||||
| @@ -69,7 +67,16 @@ def create_app(is_testing: bool = False) -> Flask: | |||||||
|     from . import auth |     from . import auth | ||||||
|     auth.init_app(app) |     auth.init_app(app) | ||||||
|  |  | ||||||
|     class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]): |     class UserUtilities(accounting.UserUtilityInterface[auth.User]): | ||||||
|  |  | ||||||
|  |         def can_view(self) -> bool: | ||||||
|  |             return auth.current_user() is not None \ | ||||||
|  |                 and auth.current_user().username in ["viewer", "editor", | ||||||
|  |                                                      "editor2"] | ||||||
|  |  | ||||||
|  |         def can_edit(self) -> bool: | ||||||
|  |             return auth.current_user() is not None \ | ||||||
|  |                 and auth.current_user().username in ["editor", "editor2"] | ||||||
|  |  | ||||||
|         @property |         @property | ||||||
|         def cls(self) -> t.Type[auth.User]: |         def cls(self) -> t.Type[auth.User]: | ||||||
| @@ -90,12 +97,7 @@ def create_app(is_testing: bool = False) -> Flask: | |||||||
|         def get_pk(self, user: auth.User) -> int: |         def get_pk(self, user: auth.User) -> int: | ||||||
|             return user.id |             return user.id | ||||||
|  |  | ||||||
|     can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \ |     accounting.init_app(app, user_utils=UserUtilities()) | ||||||
|         and auth.current_user().username in ["viewer", "editor", "editor2"] |  | ||||||
|     can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \ |  | ||||||
|         and auth.current_user().username in ["editor", "editor2"] |  | ||||||
|     accounting.init_app(app, user_utils=UserUtils(), |  | ||||||
|                         can_view_func=can_view, can_edit_func=can_edit) |  | ||||||
|  |  | ||||||
|     return app |     return app | ||||||
|  |  | ||||||
|   | |||||||
| @@ -24,8 +24,7 @@ from click.testing import Result | |||||||
| from flask import Flask | from flask import Flask | ||||||
| from flask.testing import FlaskCliRunner | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
| from test_site import create_app | from testlib import create_test_app, get_client | ||||||
| from testlib import get_client |  | ||||||
| from testlib_txn import Accounts, NEXT_URI, add_txn | from testlib_txn import Accounts, NEXT_URI, add_txn | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -38,7 +37,7 @@ class SummeryEditorTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -66,7 +65,7 @@ class SummeryEditorTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.transaction.summary_editor import SummaryEditor |         from accounting.transaction.utils.summary_editor import SummaryEditor | ||||||
|         for form in get_form_data(self.csrf_token): |         for form in get_form_data(self.csrf_token): | ||||||
|             add_txn(self.client, form) |             add_txn(self.client, form) | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -79,13 +78,13 @@ class SummeryEditorTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(editor.debit.general.tags[0].accounts[0].code, |         self.assertEqual(editor.debit.general.tags[0].accounts[0].code, | ||||||
|                          Accounts.MEAL) |                          Accounts.MEAL) | ||||||
|         self.assertEqual(editor.debit.general.tags[0].accounts[1].code, |         self.assertEqual(editor.debit.general.tags[0].accounts[1].code, | ||||||
|                          Accounts.PAYABLE) |                          Accounts.PETTY_CASH) | ||||||
|         self.assertEqual(editor.debit.general.tags[1].name, "Dinner") |         self.assertEqual(editor.debit.general.tags[1].name, "Dinner") | ||||||
|         self.assertEqual(len(editor.debit.general.tags[1].accounts), 2) |         self.assertEqual(len(editor.debit.general.tags[1].accounts), 2) | ||||||
|         self.assertEqual(editor.debit.general.tags[1].accounts[0].code, |         self.assertEqual(editor.debit.general.tags[1].accounts[0].code, | ||||||
|                          Accounts.MEAL) |                          Accounts.MEAL) | ||||||
|         self.assertEqual(editor.debit.general.tags[1].accounts[1].code, |         self.assertEqual(editor.debit.general.tags[1].accounts[1].code, | ||||||
|                          Accounts.PAYABLE) |                          Accounts.PETTY_CASH) | ||||||
|  |  | ||||||
|         # Debit-Travel |         # Debit-Travel | ||||||
|         self.assertEqual(len(editor.debit.travel.tags), 3) |         self.assertEqual(len(editor.debit.travel.tags), 3) | ||||||
| @@ -118,7 +117,7 @@ class SummeryEditorTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(editor.credit.general.tags[0].name, "Lunch") |         self.assertEqual(editor.credit.general.tags[0].name, "Lunch") | ||||||
|         self.assertEqual(len(editor.credit.general.tags[0].accounts), 3) |         self.assertEqual(len(editor.credit.general.tags[0].accounts), 3) | ||||||
|         self.assertEqual(editor.credit.general.tags[0].accounts[0].code, |         self.assertEqual(editor.credit.general.tags[0].accounts[0].code, | ||||||
|                          Accounts.PAYABLE) |                          Accounts.PETTY_CASH) | ||||||
|         self.assertEqual(editor.credit.general.tags[0].accounts[1].code, |         self.assertEqual(editor.credit.general.tags[0].accounts[1].code, | ||||||
|                          Accounts.BANK) |                          Accounts.BANK) | ||||||
|         self.assertEqual(editor.credit.general.tags[0].accounts[2].code, |         self.assertEqual(editor.credit.general.tags[0].accounts[2].code, | ||||||
| @@ -128,20 +127,20 @@ class SummeryEditorTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(editor.credit.general.tags[1].accounts[0].code, |         self.assertEqual(editor.credit.general.tags[1].accounts[0].code, | ||||||
|                          Accounts.BANK) |                          Accounts.BANK) | ||||||
|         self.assertEqual(editor.credit.general.tags[1].accounts[1].code, |         self.assertEqual(editor.credit.general.tags[1].accounts[1].code, | ||||||
|                          Accounts.PAYABLE) |                          Accounts.PETTY_CASH) | ||||||
|  |  | ||||||
|         # Credit-Travel |         # Credit-Travel | ||||||
|         self.assertEqual(len(editor.credit.travel.tags), 2) |         self.assertEqual(len(editor.credit.travel.tags), 2) | ||||||
|         self.assertEqual(editor.credit.travel.tags[0].name, "Bike") |         self.assertEqual(editor.credit.travel.tags[0].name, "Bike") | ||||||
|         self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2) |         self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2) | ||||||
|         self.assertEqual(editor.credit.travel.tags[0].accounts[0].code, |         self.assertEqual(editor.credit.travel.tags[0].accounts[0].code, | ||||||
|                          Accounts.PAYABLE) |                          Accounts.PETTY_CASH) | ||||||
|         self.assertEqual(editor.credit.travel.tags[0].accounts[1].code, |         self.assertEqual(editor.credit.travel.tags[0].accounts[1].code, | ||||||
|                          Accounts.PREPAID) |                          Accounts.PREPAID) | ||||||
|         self.assertEqual(editor.credit.travel.tags[1].name, "Taxi") |         self.assertEqual(editor.credit.travel.tags[1].name, "Taxi") | ||||||
|         self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2) |         self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2) | ||||||
|         self.assertEqual(editor.credit.travel.tags[1].accounts[0].code, |         self.assertEqual(editor.credit.travel.tags[1].accounts[0].code, | ||||||
|                          Accounts.PAYABLE) |                          Accounts.PETTY_CASH) | ||||||
|         self.assertEqual(editor.credit.travel.tags[1].accounts[1].code, |         self.assertEqual(editor.credit.travel.tags[1].accounts[1].code, | ||||||
|                          Accounts.CASH) |                          Accounts.CASH) | ||||||
|  |  | ||||||
| @@ -152,7 +151,7 @@ class SummeryEditorTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(editor.credit.bus.tags[0].accounts[0].code, |         self.assertEqual(editor.credit.bus.tags[0].accounts[0].code, | ||||||
|                          Accounts.PREPAID) |                          Accounts.PREPAID) | ||||||
|         self.assertEqual(editor.credit.bus.tags[0].accounts[1].code, |         self.assertEqual(editor.credit.bus.tags[0].accounts[1].code, | ||||||
|                          Accounts.PAYABLE) |                          Accounts.PETTY_CASH) | ||||||
|         self.assertEqual(editor.credit.bus.tags[1].name, "Bus") |         self.assertEqual(editor.credit.bus.tags[1].name, "Bus") | ||||||
|         self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1) |         self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1) | ||||||
|         self.assertEqual(editor.credit.bus.tags[1].accounts[0].code, |         self.assertEqual(editor.credit.bus.tags[1].accounts[0].code, | ||||||
| @@ -186,7 +185,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]: | |||||||
|              "currency-0-debit-1-account_code": Accounts.MEAL, |              "currency-0-debit-1-account_code": Accounts.MEAL, | ||||||
|              "currency-0-debit-1-summary": " Lunch—Fries ", |              "currency-0-debit-1-summary": " Lunch—Fries ", | ||||||
|              "currency-0-debit-1-amount": "2.15", |              "currency-0-debit-1-amount": "2.15", | ||||||
|              "currency-0-credit-1-account_code": Accounts.PAYABLE, |              "currency-0-credit-1-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-1-summary": " Lunch—Fries ", |              "currency-0-credit-1-summary": " Lunch—Fries ", | ||||||
|              "currency-0-credit-1-amount": "2.15", |              "currency-0-credit-1-amount": "2.15", | ||||||
|              "currency-0-debit-2-account_code": Accounts.MEAL, |              "currency-0-debit-2-account_code": Accounts.MEAL, | ||||||
| @@ -208,7 +207,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]: | |||||||
|              "currency-0-debit-1-account_code": Accounts.MEAL, |              "currency-0-debit-1-account_code": Accounts.MEAL, | ||||||
|              "currency-0-debit-1-summary": " Dinner—Steak  ", |              "currency-0-debit-1-summary": " Dinner—Steak  ", | ||||||
|              "currency-0-debit-1-amount": "8.28", |              "currency-0-debit-1-amount": "8.28", | ||||||
|              "currency-0-credit-1-account_code": Accounts.PAYABLE, |              "currency-0-credit-1-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-1-summary": " Dinner—Steak ", |              "currency-0-credit-1-summary": " Dinner—Steak ", | ||||||
|              "currency-0-credit-1-amount": "8.28"}, |              "currency-0-credit-1-amount": "8.28"}, | ||||||
|             {"csrf_token": csrf_token, |             {"csrf_token": csrf_token, | ||||||
| @@ -218,13 +217,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]: | |||||||
|              "currency-0-debit-0-account_code": Accounts.MEAL, |              "currency-0-debit-0-account_code": Accounts.MEAL, | ||||||
|              "currency-0-debit-0-summary": " Lunch—Pizza  ", |              "currency-0-debit-0-summary": " Lunch—Pizza  ", | ||||||
|              "currency-0-debit-0-amount": "5.49", |              "currency-0-debit-0-amount": "5.49", | ||||||
|              "currency-0-credit-0-account_code": Accounts.PAYABLE, |              "currency-0-credit-0-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-0-summary": " Lunch—Pizza ", |              "currency-0-credit-0-summary": " Lunch—Pizza ", | ||||||
|              "currency-0-credit-0-amount": "5.49", |              "currency-0-credit-0-amount": "5.49", | ||||||
|              "currency-0-debit-1-account_code": Accounts.MEAL, |              "currency-0-debit-1-account_code": Accounts.MEAL, | ||||||
|              "currency-0-debit-1-summary": " Lunch—Noodles ", |              "currency-0-debit-1-summary": " Lunch—Noodles ", | ||||||
|              "currency-0-debit-1-amount": "7.47", |              "currency-0-debit-1-amount": "7.47", | ||||||
|              "currency-0-credit-1-account_code": Accounts.PAYABLE, |              "currency-0-credit-1-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-1-summary": " Lunch—Noodles ", |              "currency-0-credit-1-summary": " Lunch—Noodles ", | ||||||
|              "currency-0-credit-1-amount": "7.47"}, |              "currency-0-credit-1-amount": "7.47"}, | ||||||
|             {"csrf_token": csrf_token, |             {"csrf_token": csrf_token, | ||||||
| @@ -259,7 +258,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]: | |||||||
|              "currency-0-debit-3-account_code": Accounts.TRAVEL, |              "currency-0-debit-3-account_code": Accounts.TRAVEL, | ||||||
|              "currency-0-debit-3-summary": " Train—Red—Mall→Museum ", |              "currency-0-debit-3-summary": " Train—Red—Mall→Museum ", | ||||||
|              "currency-0-debit-3-amount": "4.4", |              "currency-0-debit-3-amount": "4.4", | ||||||
|              "currency-0-credit-3-account_code": Accounts.PAYABLE, |              "currency-0-credit-3-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-3-summary": " Train—Red—Mall→Museum ", |              "currency-0-credit-3-summary": " Train—Red—Mall→Museum ", | ||||||
|              "currency-0-credit-3-amount": "4.4"}, |              "currency-0-credit-3-amount": "4.4"}, | ||||||
|             {"csrf_token": csrf_token, |             {"csrf_token": csrf_token, | ||||||
| @@ -275,31 +274,31 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]: | |||||||
|              "currency-0-debit-1-account_code": Accounts.TRAVEL, |              "currency-0-debit-1-account_code": Accounts.TRAVEL, | ||||||
|              "currency-0-debit-1-summary": " Taxi—Office→Restaurant ", |              "currency-0-debit-1-summary": " Taxi—Office→Restaurant ", | ||||||
|              "currency-0-debit-1-amount": "12", |              "currency-0-debit-1-amount": "12", | ||||||
|              "currency-0-credit-1-account_code": Accounts.PAYABLE, |              "currency-0-credit-1-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-1-summary": " Taxi—Office→Restaurant ", |              "currency-0-credit-1-summary": " Taxi—Office→Restaurant ", | ||||||
|              "currency-0-credit-1-amount": "12", |              "currency-0-credit-1-amount": "12", | ||||||
|              "currency-0-debit-2-account_code": Accounts.TRAVEL, |              "currency-0-debit-2-account_code": Accounts.TRAVEL, | ||||||
|              "currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ", |              "currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ", | ||||||
|              "currency-0-debit-2-amount": "8", |              "currency-0-debit-2-amount": "8", | ||||||
|              "currency-0-credit-2-account_code": Accounts.PAYABLE, |              "currency-0-credit-2-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ", |              "currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ", | ||||||
|              "currency-0-credit-2-amount": "8", |              "currency-0-credit-2-amount": "8", | ||||||
|              "currency-0-debit-3-account_code": Accounts.TRAVEL, |              "currency-0-debit-3-account_code": Accounts.TRAVEL, | ||||||
|              "currency-0-debit-3-summary": " Bike—City Hall→Office ", |              "currency-0-debit-3-summary": " Bike—City Hall→Office ", | ||||||
|              "currency-0-debit-3-amount": "3.5", |              "currency-0-debit-3-amount": "3.5", | ||||||
|              "currency-0-credit-3-account_code": Accounts.PAYABLE, |              "currency-0-credit-3-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-3-summary": " Bike—City Hall→Office ", |              "currency-0-credit-3-summary": " Bike—City Hall→Office ", | ||||||
|              "currency-0-credit-3-amount": "3.5", |              "currency-0-credit-3-amount": "3.5", | ||||||
|              "currency-0-debit-4-account_code": Accounts.TRAVEL, |              "currency-0-debit-4-account_code": Accounts.TRAVEL, | ||||||
|              "currency-0-debit-4-summary": " Bike—Restaurant→Office ", |              "currency-0-debit-4-summary": " Bike—Restaurant→Office ", | ||||||
|              "currency-0-debit-4-amount": "4", |              "currency-0-debit-4-amount": "4", | ||||||
|              "currency-0-credit-4-account_code": Accounts.PAYABLE, |              "currency-0-credit-4-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-4-summary": " Bike—Restaurant→Office ", |              "currency-0-credit-4-summary": " Bike—Restaurant→Office ", | ||||||
|              "currency-0-credit-4-amount": "4", |              "currency-0-credit-4-amount": "4", | ||||||
|              "currency-0-debit-5-account_code": Accounts.TRAVEL, |              "currency-0-debit-5-account_code": Accounts.TRAVEL, | ||||||
|              "currency-0-debit-5-summary": " Bike—Office→Theatre ", |              "currency-0-debit-5-summary": " Bike—Office→Theatre ", | ||||||
|              "currency-0-debit-5-amount": "1.5", |              "currency-0-debit-5-amount": "1.5", | ||||||
|              "currency-0-credit-5-account_code": Accounts.PAYABLE, |              "currency-0-credit-5-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-credit-5-summary": " Bike—Office→Theatre ", |              "currency-0-credit-5-summary": " Bike—Office→Theatre ", | ||||||
|              "currency-0-credit-5-amount": "1.5", |              "currency-0-credit-5-amount": "1.5", | ||||||
|              "currency-0-debit-6-account_code": Accounts.TRAVEL, |              "currency-0-debit-6-account_code": Accounts.TRAVEL, | ||||||
| @@ -312,13 +311,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]: | |||||||
|              "next": NEXT_URI, |              "next": NEXT_URI, | ||||||
|              "date": txn_date, |              "date": txn_date, | ||||||
|              "currency-0-code": "USD", |              "currency-0-code": "USD", | ||||||
|              "currency-0-debit-0-account_code": Accounts.PAYABLE, |              "currency-0-debit-0-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-debit-0-summary": " Dinner—Steak  ", |              "currency-0-debit-0-summary": " Dinner—Steak  ", | ||||||
|              "currency-0-debit-0-amount": "8.28", |              "currency-0-debit-0-amount": "8.28", | ||||||
|              "currency-0-credit-0-account_code": Accounts.BANK, |              "currency-0-credit-0-account_code": Accounts.BANK, | ||||||
|              "currency-0-credit-0-summary": " Dinner—Steak ", |              "currency-0-credit-0-summary": " Dinner—Steak ", | ||||||
|              "currency-0-credit-0-amount": "8.28", |              "currency-0-credit-0-amount": "8.28", | ||||||
|              "currency-0-debit-1-account_code": Accounts.PAYABLE, |              "currency-0-debit-1-account_code": Accounts.PETTY_CASH, | ||||||
|              "currency-0-debit-1-summary": " Lunch—Pizza ", |              "currency-0-debit-1-summary": " Lunch—Pizza ", | ||||||
|              "currency-0-debit-1-amount": "5.49", |              "currency-0-debit-1-amount": "5.49", | ||||||
|              "currency-0-credit-1-account_code": Accounts.BANK, |              "currency-0-credit-1-account_code": Accounts.BANK, | ||||||
|   | |||||||
| @@ -26,8 +26,8 @@ from click.testing import Result | |||||||
| from flask import Flask | from flask import Flask | ||||||
| from flask.testing import FlaskCliRunner | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
| from test_site import create_app, db | from test_site import db | ||||||
| from testlib import get_client | from testlib import create_test_app, get_client | ||||||
| from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \ | from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \ | ||||||
|     get_update_form, match_txn_detail, set_negative_amount, \ |     get_update_form, match_txn_detail, set_negative_amount, \ | ||||||
|     remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \ |     remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \ | ||||||
| @@ -48,7 +48,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -229,6 +229,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], create_uri) |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # A receivable entry cannot start from the credit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-credit-" in x][0] | ||||||
|  |         form[key] = Accounts.RECEIVABLE | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|         # Negative amount |         # Negative amount | ||||||
|         form = self.__get_add_form() |         form = self.__get_add_form() | ||||||
|         set_negative_amount(form) |         set_negative_amount(form) | ||||||
| @@ -380,6 +389,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], edit_uri) |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # A receivable entry cannot start from the credit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-credit-" in x][0] | ||||||
|  |         form[key] = Accounts.RECEIVABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|         # Negative amount |         # Negative amount | ||||||
|         form: dict[str, str] = form_0.copy() |         form: dict[str, str] = form_0.copy() | ||||||
|         set_negative_amount(form) |         set_negative_amount(form) | ||||||
| @@ -600,7 +618,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -781,6 +799,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], create_uri) |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # A payable entry cannot start from the debit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-debit-" in x][0] | ||||||
|  |         form[key] = Accounts.PAYABLE | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|         # Negative amount |         # Negative amount | ||||||
|         form = self.__get_add_form() |         form = self.__get_add_form() | ||||||
|         set_negative_amount(form) |         set_negative_amount(form) | ||||||
| @@ -935,6 +962,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], edit_uri) |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # A payable entry cannot start from the debit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-debit-" in x][0] | ||||||
|  |         form[key] = Accounts.PAYABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|         # Negative amount |         # Negative amount | ||||||
|         form: dict[str, str] = form_0.copy() |         form: dict[str, str] = form_0.copy() | ||||||
|         set_negative_amount(form) |         set_negative_amount(form) | ||||||
| @@ -1159,7 +1195,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
| @@ -1356,6 +1392,24 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], create_uri) |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # A receivable entry cannot start from the credit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-credit-" in x][0] | ||||||
|  |         form[key] = Accounts.RECEIVABLE | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # A payable entry cannot start from the debit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-debit-" in x][0] | ||||||
|  |         form[key] = Accounts.PAYABLE | ||||||
|  |         response = self.client.post(store_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|         # Negative amount |         # Negative amount | ||||||
|         form = self.__get_add_form() |         form = self.__get_add_form() | ||||||
|         set_negative_amount(form) |         set_negative_amount(form) | ||||||
| @@ -1537,6 +1591,24 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], edit_uri) |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # A receivable entry cannot start from the credit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-credit-" in x][0] | ||||||
|  |         form[key] = Accounts.RECEIVABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # A payable entry cannot start from the debit side | ||||||
|  |         form = self.__get_add_form() | ||||||
|  |         key: str = [x for x in form.keys() | ||||||
|  |                     if x.endswith("-account_code") and "-debit-" in x][0] | ||||||
|  |         form[key] = Accounts.PAYABLE | ||||||
|  |         response = self.client.post(update_uri, data=form) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|         # Negative amount |         # Negative amount | ||||||
|         form: dict[str, str] = form_0.copy() |         form: dict[str, str] = form_0.copy() | ||||||
|         set_negative_amount(form) |         set_negative_amount(form) | ||||||
| @@ -1973,7 +2045,7 @@ class TransactionReorderTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|   | |||||||
| @@ -21,13 +21,12 @@ import unittest | |||||||
| from urllib.parse import quote_plus | from urllib.parse import quote_plus | ||||||
|  |  | ||||||
| import httpx | import httpx | ||||||
| from flask import Flask, request, render_template_string | from flask import Flask, request | ||||||
|  |  | ||||||
| from accounting.utils.next_uri import append_next, inherit_next, or_next | from accounting.utils.next_uri import append_next, inherit_next, or_next | ||||||
| from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE | from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE | ||||||
| from accounting.utils.query import parse_query_keywords | from accounting.utils.query import parse_query_keywords | ||||||
| from test_site import create_app | from testlib import TEST_SERVER, create_test_app, get_csrf_token | ||||||
| from testlib import TEST_SERVER |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class NextUriTestCase(unittest.TestCase): | class NextUriTestCase(unittest.TestCase): | ||||||
| @@ -40,12 +39,7 @@ class NextUriTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|  |  | ||||||
|         @self.app.get("/test-csrf") |  | ||||||
|         def test_csrf() -> str: |  | ||||||
|             """The test view to return the CSRF token.""" |  | ||||||
|             return render_template_string("{{csrf_token()}}") |  | ||||||
|  |  | ||||||
|     def test_next_uri(self) -> None: |     def test_next_uri(self) -> None: | ||||||
|         """Tests the next URI utilities with the next URI. |         """Tests the next URI utilities with the next URI. | ||||||
| @@ -69,7 +63,7 @@ class NextUriTestCase(unittest.TestCase): | |||||||
|                               methods=["GET", "POST"]) |                               methods=["GET", "POST"]) | ||||||
|         client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) |         client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) | ||||||
|         client.headers["Referer"] = TEST_SERVER |         client.headers["Referer"] = TEST_SERVER | ||||||
|         csrf_token: str = client.get("/test-csrf").text |         csrf_token: str = get_csrf_token(client) | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
|  |  | ||||||
|         response = client.get("/test-next?next=/next&q=abc&page-no=4") |         response = client.get("/test-next?next=/next&q=abc&page-no=4") | ||||||
| @@ -98,7 +92,7 @@ class NextUriTestCase(unittest.TestCase): | |||||||
|                               methods=["GET", "POST"]) |                               methods=["GET", "POST"]) | ||||||
|         client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) |         client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) | ||||||
|         client.headers["Referer"] = TEST_SERVER |         client.headers["Referer"] = TEST_SERVER | ||||||
|         csrf_token: str = client.get("/test-csrf").text |         csrf_token: str = get_csrf_token(client) | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
|  |  | ||||||
|         response = client.get("/test-no-next?q=abc&page-no=4") |         response = client.get("/test-no-next?q=abc&page-no=4") | ||||||
| @@ -158,7 +152,7 @@ class PaginationTestCase(unittest.TestCase): | |||||||
|             :param items: All the items in the list. |             :param items: All the items in the list. | ||||||
|             :param is_reversed: Whether the default page is the last page. |             :param is_reversed: Whether the default page is the last page. | ||||||
|             :param result: The expected items on the page. |             :param result: The expected items on the page. | ||||||
|             :param is_paged: Whether the pagination is needed. |             :param is_paged: Whether we need pagination. | ||||||
|             """ |             """ | ||||||
|             self.items: list[int] = items |             self.items: list[int] = items | ||||||
|             self.is_reversed: bool | None = is_reversed |             self.is_reversed: bool | None = is_reversed | ||||||
| @@ -171,7 +165,7 @@ class PaginationTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_test_app() | ||||||
|         self.params = self.Params([], None, [], True) |         self.params = self.Params([], None, [], True) | ||||||
|  |  | ||||||
|         @self.app.get("/test-pagination") |         @self.app.get("/test-pagination") | ||||||
| @@ -198,7 +192,7 @@ class PaginationTestCase(unittest.TestCase): | |||||||
|         :param query: The query string. |         :param query: The query string. | ||||||
|         :param items: The original items. |         :param items: The original items. | ||||||
|         :param result: The expected page content. |         :param result: The expected page content. | ||||||
|         :param is_paged: Whether the pagination is needed. |         :param is_paged: Whether we need pagination. | ||||||
|         :param is_reversed: Whether the list is reversed. |         :param is_reversed: Whether the list is reversed. | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
| @@ -253,8 +247,8 @@ class PaginationTestCase(unittest.TestCase): | |||||||
|         self.__test_success("page-no=46&page-size=15", range(1, 687), |         self.__test_success("page-no=46&page-size=15", range(1, 687), | ||||||
|                             range(676, 687)) |                             range(676, 687)) | ||||||
|  |  | ||||||
|     def test_not_needed(self) -> None: |     def test_not_need(self) -> None: | ||||||
|         """Tests the pagination that is not needed. |         """Tests that the data does not need pagination. | ||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|   | |||||||
| @@ -18,15 +18,46 @@ | |||||||
|  |  | ||||||
| """ | """ | ||||||
| import typing as t | import typing as t | ||||||
| from html.parser import HTMLParser |  | ||||||
|  |  | ||||||
| import httpx | import httpx | ||||||
| from flask import Flask | from flask import Flask, render_template_string | ||||||
|  |  | ||||||
|  | from test_site import create_app | ||||||
|  |  | ||||||
| TEST_SERVER: str = "https://testserver" | TEST_SERVER: str = "https://testserver" | ||||||
| """The test server URI.""" | """The test server URI.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def create_test_app() -> Flask: | ||||||
|  |     """Creates and returns the testing Flask application. | ||||||
|  |  | ||||||
|  |     :return: The testing Flask application. | ||||||
|  |     """ | ||||||
|  |     app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|  |     @app.get("/.csrf-token") | ||||||
|  |     def get_csrf_token_view() -> str: | ||||||
|  |         """The test view to return the CSRF token.""" | ||||||
|  |         return render_template_string("{{csrf_token()}}") | ||||||
|  |  | ||||||
|  |     @app.get("/.errors") | ||||||
|  |     def get_errors_view() -> str: | ||||||
|  |         """The test view to return the CSRF token.""" | ||||||
|  |         return render_template_string("{{get_flashed_messages()|tojson}}") | ||||||
|  |  | ||||||
|  |     return app | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_csrf_token(client: httpx.Client) -> str: | ||||||
|  |     """Returns the CSRF token. | ||||||
|  |  | ||||||
|  |     :param client: The httpx client. | ||||||
|  |     :return: The CSRF token. | ||||||
|  |     """ | ||||||
|  |     return client.get("/.csrf-token").text | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: | def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: | ||||||
|     """Returns a user client. |     """Returns a user client. | ||||||
|  |  | ||||||
| @@ -36,7 +67,7 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: | |||||||
|     """ |     """ | ||||||
|     client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER) |     client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER) | ||||||
|     client.headers["Referer"] = TEST_SERVER |     client.headers["Referer"] = TEST_SERVER | ||||||
|     csrf_token: str = get_csrf_token(client, "/login") |     csrf_token: str = get_csrf_token(client) | ||||||
|     response: httpx.Response = client.post("/login", |     response: httpx.Response = client.post("/login", | ||||||
|                                            data={"csrf_token": csrf_token, |                                            data={"csrf_token": csrf_token, | ||||||
|                                                  "username": username}) |                                                  "username": username}) | ||||||
| @@ -45,38 +76,6 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: | |||||||
|     return client, csrf_token |     return client, csrf_token | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_csrf_token(client: httpx.Client, uri: str) -> str: |  | ||||||
|     """Returns the CSRF token from a form in a URI. |  | ||||||
|  |  | ||||||
|     :param client: The httpx client. |  | ||||||
|     :param uri: The URI. |  | ||||||
|     :return: The CSRF token. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     class CsrfParser(HTMLParser): |  | ||||||
|         """The CSRF token parser.""" |  | ||||||
|  |  | ||||||
|         def __init__(self): |  | ||||||
|             """Constructs the CSRF token parser.""" |  | ||||||
|             super().__init__() |  | ||||||
|             self.csrf_token: str | None = None |  | ||||||
|             """The CSRF token.""" |  | ||||||
|  |  | ||||||
|         def handle_starttag(self, tag: str, |  | ||||||
|                             attrs: list[tuple[str, str | None]]) -> None: |  | ||||||
|             """Handles when a start tag is found.""" |  | ||||||
|             attrs_dict: dict[str, str] = dict(attrs) |  | ||||||
|             if attrs_dict.get("name") == "csrf_token": |  | ||||||
|                 self.csrf_token = attrs_dict["value"] |  | ||||||
|  |  | ||||||
|     response: httpx.Response = client.get(uri) |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     parser: CsrfParser = CsrfParser() |  | ||||||
|     parser.feed(response.text) |  | ||||||
|     assert parser.csrf_token is not None |  | ||||||
|     return parser.csrf_token |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def set_locale(client: httpx.Client, csrf_token: str, | def set_locale(client: httpx.Client, csrf_token: str, | ||||||
|                locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None: |                locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None: | ||||||
|     """Sets the current locale. |     """Sets the current locale. | ||||||
|   | |||||||
							
								
								
									
										309
									
								
								tests/testlib_offset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								tests/testlib_offset.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,309 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The common test libraries for the offset test cases. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from datetime import date, timedelta | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import httpx | ||||||
|  | from flask import Flask | ||||||
|  |  | ||||||
|  | from test_site import db | ||||||
|  | from testlib_txn import Accounts, match_txn_detail, NEXT_URI | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryData: | ||||||
|  |     """The journal entry data.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, account: str, summary: str, amount: str, | ||||||
|  |                  original_entry: JournalEntryData | None = None): | ||||||
|  |         """Constructs the journal entry data. | ||||||
|  |  | ||||||
|  |         :param account: The account code. | ||||||
|  |         :param summary: The summary. | ||||||
|  |         :param amount: The amount. | ||||||
|  |         :param original_entry: The original entry. | ||||||
|  |         """ | ||||||
|  |         self.txn: TransactionData | None = None | ||||||
|  |         self.id: int = -1 | ||||||
|  |         self.no: int = -1 | ||||||
|  |         self.original_entry: JournalEntryData | None = original_entry | ||||||
|  |         self.account: str = account | ||||||
|  |         self.summary: str = summary | ||||||
|  |         self.amount: Decimal = Decimal(amount) | ||||||
|  |  | ||||||
|  |     def form(self, prefix: str, entry_type: str, index: int, is_update: bool) \ | ||||||
|  |             -> dict[str, str]: | ||||||
|  |         """Returns the journal entry as form data. | ||||||
|  |  | ||||||
|  |         :param prefix: The prefix of the form fields. | ||||||
|  |         :param entry_type: The entry type, either "debit" or "credit". | ||||||
|  |         :param index: The entry index. | ||||||
|  |         :param is_update: True for an update operation, or False otherwise | ||||||
|  |         :return: The form data. | ||||||
|  |         """ | ||||||
|  |         prefix = f"{prefix}-{entry_type}-{index}" | ||||||
|  |         form: dict[str, str] = {f"{prefix}-account_code": self.account, | ||||||
|  |                                 f"{prefix}-summary": self.summary, | ||||||
|  |                                 f"{prefix}-amount": str(self.amount)} | ||||||
|  |         if is_update and self.id != -1: | ||||||
|  |             form[f"{prefix}-eid"] = str(self.id) | ||||||
|  |         form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no) | ||||||
|  |         if self.original_entry is not None: | ||||||
|  |             assert self.original_entry.id != -1 | ||||||
|  |             form[f"{prefix}-original_entry_id"] = str(self.original_entry.id) | ||||||
|  |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyData: | ||||||
|  |     """The transaction currency data.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: str, debit: list[JournalEntryData], | ||||||
|  |                  credit: list[JournalEntryData]): | ||||||
|  |         """Constructs the transaction currency data. | ||||||
|  |  | ||||||
|  |         :param currency: The currency code. | ||||||
|  |         :param debit: The debit journal entries. | ||||||
|  |         :param credit: The credit journal entries. | ||||||
|  |         """ | ||||||
|  |         self.code: str = currency | ||||||
|  |         self.debit: list[JournalEntryData] = debit | ||||||
|  |         self.credit: list[JournalEntryData] = credit | ||||||
|  |  | ||||||
|  |     def form(self, index: int, is_update: bool) -> dict[str, str]: | ||||||
|  |         """Returns the currency as form data. | ||||||
|  |  | ||||||
|  |         :param index: The currency index. | ||||||
|  |         :param is_update: True for an update operation, or False otherwise | ||||||
|  |         :return: The form data. | ||||||
|  |         """ | ||||||
|  |         prefix: str = f"currency-{index}" | ||||||
|  |         form: dict[str, str] = {f"{prefix}-code": self.code} | ||||||
|  |         for i in range(len(self.debit)): | ||||||
|  |             form.update(self.debit[i].form(prefix, "debit", i + 1, is_update)) | ||||||
|  |         for i in range(len(self.credit)): | ||||||
|  |             form.update(self.credit[i].form(prefix, "credit", i + 1, | ||||||
|  |                                             is_update)) | ||||||
|  |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionData: | ||||||
|  |     """The transaction data.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, days: int, currencies: list[CurrencyData]): | ||||||
|  |         """Constructs a transaction. | ||||||
|  |  | ||||||
|  |         :param days: The number of days before today. | ||||||
|  |         :param currencies: The transaction currency data. | ||||||
|  |         """ | ||||||
|  |         self.id: int = -1 | ||||||
|  |         self.days: int = days | ||||||
|  |         self.currencies: list[CurrencyData] = currencies | ||||||
|  |         self.note: str | None = None | ||||||
|  |         for currency in self.currencies: | ||||||
|  |             for entry in currency.debit: | ||||||
|  |                 entry.txn = self | ||||||
|  |             for entry in currency.credit: | ||||||
|  |                 entry.txn = self | ||||||
|  |  | ||||||
|  |     def new_form(self, csrf_token: str) -> dict[str, str]: | ||||||
|  |         """Returns the transaction as a form. | ||||||
|  |  | ||||||
|  |         :param csrf_token: The CSRF token. | ||||||
|  |         :return: The transaction as a form. | ||||||
|  |         """ | ||||||
|  |         return self.__form(csrf_token, is_update=False) | ||||||
|  |  | ||||||
|  |     def update_form(self, csrf_token: str) -> dict[str, str]: | ||||||
|  |         """Returns the transaction as a form. | ||||||
|  |  | ||||||
|  |         :param csrf_token: The CSRF token. | ||||||
|  |         :return: The transaction as a form. | ||||||
|  |         """ | ||||||
|  |         return self.__form(csrf_token, is_update=True) | ||||||
|  |  | ||||||
|  |     def __form(self, csrf_token: str, is_update: bool = False) \ | ||||||
|  |             -> dict[str, str]: | ||||||
|  |         """Returns the transaction as a form. | ||||||
|  |  | ||||||
|  |         :param csrf_token: The CSRF token. | ||||||
|  |         :param is_update: True for an update operation, or False otherwise | ||||||
|  |         :return: The transaction as a form. | ||||||
|  |         """ | ||||||
|  |         txn_date: date = date.today() - timedelta(days=self.days) | ||||||
|  |         form: dict[str, str] = {"csrf_token": csrf_token, | ||||||
|  |                                 "next": NEXT_URI, | ||||||
|  |                                 "date": txn_date.isoformat()} | ||||||
|  |         for i in range(len(self.currencies)): | ||||||
|  |             form.update(self.currencies[i].form(i + 1, is_update)) | ||||||
|  |         if self.note is not None: | ||||||
|  |             form["note"] = self.note | ||||||
|  |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestData: | ||||||
|  |     """The test data.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, app: Flask, client: httpx.Client, csrf_token: str): | ||||||
|  |         """Constructs the test data. | ||||||
|  |  | ||||||
|  |         :param app: The Flask application. | ||||||
|  |         :param client: The client. | ||||||
|  |         :param csrf_token: The CSRF token. | ||||||
|  |         """ | ||||||
|  |         self.app: Flask = app | ||||||
|  |         self.client: httpx.Client = client | ||||||
|  |         self.csrf_token: str = csrf_token | ||||||
|  |  | ||||||
|  |         def couple(summary: str, amount: str, debit: str, credit: str) \ | ||||||
|  |                 -> tuple[JournalEntryData, JournalEntryData]: | ||||||
|  |             """Returns a couple of debit-credit journal entries. | ||||||
|  |  | ||||||
|  |             :param summary: The summary. | ||||||
|  |             :param amount: The amount. | ||||||
|  |             :param debit: The debit account code. | ||||||
|  |             :param credit: The credit account code. | ||||||
|  |             :return: The debit journal entry and credit journal entry. | ||||||
|  |             """ | ||||||
|  |             return JournalEntryData(debit, summary, amount),\ | ||||||
|  |                 JournalEntryData(credit, summary, amount) | ||||||
|  |  | ||||||
|  |         # Receivable original entries | ||||||
|  |         self.e_r_or1d, self.e_r_or1c = couple( | ||||||
|  |             "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE) | ||||||
|  |         self.e_r_or2d, self.e_r_or2c = couple( | ||||||
|  |             "Toy", "600", Accounts.RECEIVABLE, Accounts.SALES) | ||||||
|  |         self.e_r_or3d, self.e_r_or3c = couple( | ||||||
|  |             "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES) | ||||||
|  |         self.e_r_or4d, self.e_r_or4c = couple( | ||||||
|  |             "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST) | ||||||
|  |  | ||||||
|  |         # Payable original entries | ||||||
|  |         self.e_p_or1d, self.e_p_or1c = couple( | ||||||
|  |             "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE) | ||||||
|  |         self.e_p_or2d, self.e_p_or2c = couple( | ||||||
|  |             "Phone", "900", Accounts.OFFICE, Accounts.PAYABLE) | ||||||
|  |         self.e_p_or3d, self.e_p_or3c = couple( | ||||||
|  |             "Steak", "120", Accounts.MEAL, Accounts.PAYABLE) | ||||||
|  |         self.e_p_or4d, self.e_p_or4c = couple( | ||||||
|  |             "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE) | ||||||
|  |  | ||||||
|  |         # Original transactions | ||||||
|  |         self.t_r_or1: TransactionData = TransactionData( | ||||||
|  |             50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d], | ||||||
|  |                               [self.e_r_or1c, self.e_r_or4c])]) | ||||||
|  |         self.t_r_or2: TransactionData = TransactionData( | ||||||
|  |             30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d], | ||||||
|  |                               [self.e_r_or2c, self.e_r_or3c])]) | ||||||
|  |         self.t_p_or1: TransactionData = TransactionData( | ||||||
|  |             40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d], | ||||||
|  |                               [self.e_p_or1c, self.e_p_or4c])]) | ||||||
|  |         self.t_p_or2: TransactionData = TransactionData( | ||||||
|  |             20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d], | ||||||
|  |                               [self.e_p_or2c, self.e_p_or3c])]) | ||||||
|  |  | ||||||
|  |         self.__add_txn(self.t_r_or1) | ||||||
|  |         self.__add_txn(self.t_r_or2) | ||||||
|  |         self.__add_txn(self.t_p_or1) | ||||||
|  |         self.__add_txn(self.t_p_or2) | ||||||
|  |  | ||||||
|  |         # Receivable offset entries | ||||||
|  |         self.e_r_of1d, self.e_r_of1c = couple( | ||||||
|  |             "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE) | ||||||
|  |         self.e_r_of1c.original_entry = self.e_r_or1d | ||||||
|  |         self.e_r_of2d, self.e_r_of2c = couple( | ||||||
|  |             "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE) | ||||||
|  |         self.e_r_of2c.original_entry = self.e_r_or1d | ||||||
|  |         self.e_r_of3d, self.e_r_of3c = couple( | ||||||
|  |             "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE) | ||||||
|  |         self.e_r_of3c.original_entry = self.e_r_or1d | ||||||
|  |         self.e_r_of4d, self.e_r_of4c = couple( | ||||||
|  |             "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE) | ||||||
|  |         self.e_r_of4c.original_entry = self.e_r_or2d | ||||||
|  |         self.e_r_of5d, self.e_r_of5c = couple( | ||||||
|  |             "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE) | ||||||
|  |         self.e_r_of5c.original_entry = self.e_r_or4d | ||||||
|  |  | ||||||
|  |         # Payable offset entries | ||||||
|  |         self.e_p_of1d, self.e_p_of1c = couple( | ||||||
|  |             "Airplane", "800", Accounts.PAYABLE, Accounts.CASH) | ||||||
|  |         self.e_p_of1d.original_entry = self.e_p_or1c | ||||||
|  |         self.e_p_of2d, self.e_p_of2c = couple( | ||||||
|  |             "Airplane", "300", Accounts.PAYABLE, Accounts.CASH) | ||||||
|  |         self.e_p_of2d.original_entry = self.e_p_or1c | ||||||
|  |         self.e_p_of3d, self.e_p_of3c = couple( | ||||||
|  |             "Airplane", "100", Accounts.PAYABLE, Accounts.CASH) | ||||||
|  |         self.e_p_of3d.original_entry = self.e_p_or1c | ||||||
|  |         self.e_p_of4d, self.e_p_of4c = couple( | ||||||
|  |             "Phone", "400", Accounts.PAYABLE, Accounts.CASH) | ||||||
|  |         self.e_p_of4d.original_entry = self.e_p_or2c | ||||||
|  |         self.e_p_of5d, self.e_p_of5c = couple( | ||||||
|  |             "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH) | ||||||
|  |         self.e_p_of5d.original_entry = self.e_p_or4c | ||||||
|  |  | ||||||
|  |         # Offset transactions | ||||||
|  |         self.t_r_of1: TransactionData = TransactionData( | ||||||
|  |             25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])]) | ||||||
|  |         self.t_r_of2: TransactionData = TransactionData( | ||||||
|  |             20, [CurrencyData("USD", | ||||||
|  |                               [self.e_r_of2d, self.e_r_of3d, self.e_r_of4d], | ||||||
|  |                               [self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])]) | ||||||
|  |         self.t_r_of3: TransactionData = TransactionData( | ||||||
|  |             15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])]) | ||||||
|  |         self.t_p_of1: TransactionData = TransactionData( | ||||||
|  |             15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])]) | ||||||
|  |         self.t_p_of2: TransactionData = TransactionData( | ||||||
|  |             10, [CurrencyData("USD", | ||||||
|  |                               [self.e_p_of2d, self.e_p_of3d, self.e_p_of4d], | ||||||
|  |                               [self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])]) | ||||||
|  |         self.t_p_of3: TransactionData = TransactionData( | ||||||
|  |             5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])]) | ||||||
|  |  | ||||||
|  |         self.__add_txn(self.t_r_of1) | ||||||
|  |         self.__add_txn(self.t_r_of2) | ||||||
|  |         self.__add_txn(self.t_r_of3) | ||||||
|  |         self.__add_txn(self.t_p_of1) | ||||||
|  |         self.__add_txn(self.t_p_of2) | ||||||
|  |         self.__add_txn(self.t_p_of3) | ||||||
|  |  | ||||||
|  |     def __add_txn(self, txn_data: TransactionData) -> None: | ||||||
|  |         """Adds a transaction. | ||||||
|  |  | ||||||
|  |         :param txn_data: The transaction data. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Transaction | ||||||
|  |         store_uri: str = "/accounting/transactions/store/transfer" | ||||||
|  |  | ||||||
|  |         response: httpx.Response = self.client.post( | ||||||
|  |             store_uri, data=txn_data.new_form(self.csrf_token)) | ||||||
|  |         assert response.status_code == 302 | ||||||
|  |         txn_id: int = match_txn_detail(response.headers["Location"]) | ||||||
|  |         txn_data.id = txn_id | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             txn: Transaction | None = db.session.get(Transaction, txn_id) | ||||||
|  |             assert txn is not None | ||||||
|  |             for i in range(len(txn.currencies)): | ||||||
|  |                 for j in range(len(txn.currencies[i].debit)): | ||||||
|  |                     txn_data.currencies[i].debit[j].id \ | ||||||
|  |                         = txn.currencies[i].debit[j].id | ||||||
|  |                 for j in range(len(txn.currencies[i].credit)): | ||||||
|  |                     txn_data.currencies[i].credit[j].id \ | ||||||
|  |                         = txn.currencies[i].credit[j].id | ||||||
| @@ -38,8 +38,12 @@ EMPTY_NOTE: str = " \n\n  " | |||||||
| class Accounts: | class Accounts: | ||||||
|     """The shortcuts to the common accounts.""" |     """The shortcuts to the common accounts.""" | ||||||
|     CASH: str = "1111-001" |     CASH: str = "1111-001" | ||||||
|  |     PETTY_CASH: str = "1112-001" | ||||||
|     BANK: str = "1113-001" |     BANK: str = "1113-001" | ||||||
|  |     NOTES_RECEIVABLE: str = "1131-001" | ||||||
|  |     RECEIVABLE: str = "1141-001" | ||||||
|     PREPAID: str = "1258-001" |     PREPAID: str = "1258-001" | ||||||
|  |     NOTES_PAYABLE: str = "2131-001" | ||||||
|     PAYABLE: str = "2141-001" |     PAYABLE: str = "2141-001" | ||||||
|     SALES: str = "4111-001" |     SALES: str = "4111-001" | ||||||
|     SERVICE: str = "4611-001" |     SERVICE: str = "4611-001" | ||||||
| @@ -47,7 +51,7 @@ class Accounts: | |||||||
|     OFFICE: str = "6153-001" |     OFFICE: str = "6153-001" | ||||||
|     TRAVEL: str = "6154-001" |     TRAVEL: str = "6154-001" | ||||||
|     MEAL: str = "6172-001" |     MEAL: str = "6172-001" | ||||||
|     INTEREST: str = "4111-001" |     INTEREST: str = "7111-001" | ||||||
|     DONATION: str = "7481-001" |     DONATION: str = "7481-001" | ||||||
|     RENT: str = "7482-001" |     RENT: str = "7482-001" | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user