Compare commits
	
		
			112 Commits
		
	
	
		
			v0.7.0
			...
			7c512b1c15
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7c512b1c15 | |||
| dc432da398 | |||
| c8504bcbf5 | |||
| c865141583 | |||
| 8c1ecd6eac | |||
| e8e4100677 | |||
| 6a8773c531 | |||
| 30e0c7682c | |||
| eb5a7bef7e | |||
| 8a174d8847 | |||
| 7459afd63a | |||
| a9afc385e9 | |||
| a8be739ec7 | |||
| 0130bc58a9 | |||
| 821059fa80 | |||
| 5b4f57d0b3 | |||
| 4bfac2d545 | |||
| f105f0cf7b | |||
| 5e320729d7 | |||
| 7515032082 | |||
| 361b18e411 | |||
| 7d084e570e | |||
| cb397910f8 | |||
| 5f8b0dec98 | |||
| 8398d1e8bb | |||
| 562801692a | |||
| faee1e61c6 | |||
| 57a4177037 | |||
| fa1dedf207 | |||
| 7ed13dc0af | |||
| 52807c5322 | |||
| 231a71feea | |||
| 4902eecae0 | |||
| 889e4c058e | |||
| 7262a6cb42 | |||
| c4ff4ecb3d | |||
| 2859f628ea | |||
| e0355b2af1 | |||
| b4d390c33a | |||
| a4ab8a761c | |||
| 907ce6d06e | |||
| 7e1388735e | |||
| 6f773dd837 | |||
| 87fa5aa6bc | |||
| 35e05b3708 | |||
| 7ccc96bda0 | |||
| 283758ebe9 | |||
| b673c7aeaf | |||
| 0ad2ac53dd | |||
| 7e90ec5a8f | |||
| 7755365467 | |||
| 979eea606a | |||
| 5a9e08f2c4 | |||
| 68c810d492 | |||
| 5f88260507 | |||
| 779d89f8c4 | |||
| 5d4bf4361b | |||
| 10170d613d | |||
| c885c08c37 | |||
| e2a4340f2a | |||
| 9728ff30e0 | |||
| a4644ede5f | |||
| 8f477dd6f1 | |||
| 44ac53f15c | |||
| 5edb5465c5 | |||
| 067afdb165 | |||
| 37a4c26f86 | |||
| 89e43830b4 | |||
| 671dbfb692 | |||
| 2014344d25 | |||
| f9c39709c8 | |||
| b394c58ec6 | |||
| 0af3e2785b | |||
| 7066f75e72 | |||
| 619540da49 | |||
| 567004f7d9 | |||
| 761d5a5824 | |||
| fa3cdace7f | |||
| 656762850c | |||
| e2325f08d0 | |||
| 855356084e | |||
| 7aaeb32a3d | |||
| b376cf1580 | |||
| ccbdc779ac | |||
| 61ee08fda2 | |||
| d8afadda02 | |||
| c8e1270d8f | |||
| 2a78799404 | |||
| 863d7a9368 | |||
| 6fd37b21d9 | |||
| bbf3ee3320 | |||
| b60cc7902d | |||
| 623313b58a | |||
| d0d2d77a2e | |||
| 494faeffea | |||
| 871a5fd1d8 | |||
| e615ad2690 | |||
| da92a0b42c | |||
| 678d0aa773 | |||
| 9248ba7e3b | |||
| 446087b212 | |||
| a42e7d13a2 | |||
| a82f5091f1 | |||
| 3455827c09 | |||
| 5dccf99a55 | |||
| 8818b46e01 | |||
| 2f3ad99467 | |||
| 592910187b | |||
| cb7a0d377f | |||
| 79175285f8 | |||
| fef474977c | |||
| fa1a55cd3d | 
							
								
								
									
										29
									
								
								docs/source/accounting.option.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								docs/source/accounting.option.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
accounting.option package
 | 
			
		||||
=========================
 | 
			
		||||
 | 
			
		||||
Submodules
 | 
			
		||||
----------
 | 
			
		||||
 | 
			
		||||
accounting.option.forms module
 | 
			
		||||
------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.option.forms
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.option.views module
 | 
			
		||||
------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.option.views
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
Module contents
 | 
			
		||||
---------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.option
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
@@ -28,14 +28,6 @@ accounting.report.utils.csv\_export module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.report.utils.ie\_account module
 | 
			
		||||
------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.report.utils.ie_account
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.report.utils.option\_link module
 | 
			
		||||
-------------------------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -11,12 +11,21 @@ Subpackages
 | 
			
		||||
   accounting.base_account
 | 
			
		||||
   accounting.currency
 | 
			
		||||
   accounting.journal_entry
 | 
			
		||||
   accounting.option
 | 
			
		||||
   accounting.report
 | 
			
		||||
   accounting.utils
 | 
			
		||||
 | 
			
		||||
Submodules
 | 
			
		||||
----------
 | 
			
		||||
 | 
			
		||||
accounting.forms module
 | 
			
		||||
-----------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.forms
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.locale module
 | 
			
		||||
------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,14 @@ accounting.utils.cast module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.current\_account module
 | 
			
		||||
----------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.current_account
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.flash\_errors module
 | 
			
		||||
-------------------------------------
 | 
			
		||||
 | 
			
		||||
@@ -36,6 +44,14 @@ accounting.utils.next\_uri module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.options module
 | 
			
		||||
-------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.options
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.pagination module
 | 
			
		||||
----------------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
 | 
			
		||||
project = 'Mia! Accounting Flask'
 | 
			
		||||
copyright = '2023, imacat'
 | 
			
		||||
author = 'imacat'
 | 
			
		||||
release = '0.7.0'
 | 
			
		||||
release = '0.9.0'
 | 
			
		||||
 | 
			
		||||
# -- General configuration ---------------------------------------------------
 | 
			
		||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
 | 
			
		||||
[metadata]
 | 
			
		||||
name = mia-accounting-flask
 | 
			
		||||
version = 0.7.0
 | 
			
		||||
version = 0.9.0
 | 
			
		||||
author = imacat
 | 
			
		||||
author_email = imacat@mail.imacat.idv.tw
 | 
			
		||||
description = The Mia! Accounting Flask project.
 | 
			
		||||
 
 | 
			
		||||
@@ -86,4 +86,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
 | 
			
		||||
    from . import report
 | 
			
		||||
    report.init_app(app, bp)
 | 
			
		||||
 | 
			
		||||
    from . import option
 | 
			
		||||
    option.init_app(bp)
 | 
			
		||||
 | 
			
		||||
    app.register_blueprint(bp)
 | 
			
		||||
 
 | 
			
		||||
@@ -157,6 +157,9 @@ def delete_account(account: Account) -> redirect:
 | 
			
		||||
    :return: The redirection to the account list on success, or the account
 | 
			
		||||
        detail on error.
 | 
			
		||||
    """
 | 
			
		||||
    if not account.can_delete:
 | 
			
		||||
        flash(s(lazy_gettext("The account cannot be deleted.")), "error")
 | 
			
		||||
        return redirect(inherit_next(__get_detail_uri(account)))
 | 
			
		||||
    account.delete()
 | 
			
		||||
    sort_accounts_in(account.base_code, account.id)
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
 
 | 
			
		||||
@@ -160,6 +160,9 @@ def delete_currency(currency: Currency) -> redirect:
 | 
			
		||||
    :return: The redirection to the currency list on success, or the currency
 | 
			
		||||
        detail on error.
 | 
			
		||||
    """
 | 
			
		||||
    if not currency.can_delete:
 | 
			
		||||
        flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
 | 
			
		||||
        return redirect(inherit_next(__get_detail_uri(currency)))
 | 
			
		||||
    currency.delete()
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										96
									
								
								src/accounting/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/accounting/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,96 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
 | 
			
		||||
 | 
			
		||||
#  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.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField, ValidationError
 | 
			
		||||
from wtforms.validators import DataRequired
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Currency, Account
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
 | 
			
		||||
    lazy_gettext("Please select the account."))
 | 
			
		||||
"""The validator to check if the account 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 AccountExists:
 | 
			
		||||
    """The validator to check if the account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if Account.find_by_code(field.data) is None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The account does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsDebitAccount:
 | 
			
		||||
    """The validator to check if the account is for debit line items."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, message: str | LazyString):
 | 
			
		||||
        """Constructs the validator.
 | 
			
		||||
 | 
			
		||||
        :param message: The error message.
 | 
			
		||||
        """
 | 
			
		||||
        self.__message: str | LazyString = message
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[1235689]|7[5678])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(self.__message)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsCreditAccount:
 | 
			
		||||
    """The validator to check if the account is for credit line items."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, message: str | LazyString):
 | 
			
		||||
        """Constructs the validator.
 | 
			
		||||
 | 
			
		||||
        :param message: The error message.
 | 
			
		||||
        """
 | 
			
		||||
        self.__message: str | LazyString = message
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[123489]|7[1234])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(self.__message)
 | 
			
		||||
@@ -27,29 +27,20 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
 | 
			
		||||
from wtforms.validators import DataRequired
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Currency, JournalEntryLineItem
 | 
			
		||||
from accounting.forms import CurrencyExists
 | 
			
		||||
from accounting.journal_entry.utils.offset_alias import offset_alias
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import JournalEntryLineItem
 | 
			
		||||
from accounting.utils.cast import be
 | 
			
		||||
from accounting.utils.strip_text import strip_text
 | 
			
		||||
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
 | 
			
		||||
    lazy_gettext("Please select the currency."))
 | 
			
		||||
"""The validator to check if the currency code is empty."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyExists:
 | 
			
		||||
    """The validator to check if the account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if db.session.get(Currency, field.data) is None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The currency does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SameCurrencyAsOriginalLineItems:
 | 
			
		||||
    """The validator to check if the currency is the same as the
 | 
			
		||||
    original line items."""
 | 
			
		||||
 
 | 
			
		||||
@@ -220,7 +220,7 @@ class JournalEntryForm(FlaskForm):
 | 
			
		||||
        :return: The selectable debit accounts.
 | 
			
		||||
        """
 | 
			
		||||
        accounts: list[AccountOption] \
 | 
			
		||||
            = [AccountOption(x) for x in Account.debit()
 | 
			
		||||
            = [AccountOption(x) for x in Account.selectable_debit()
 | 
			
		||||
               if not (x.code[0] == "2" and x.is_need_offset)]
 | 
			
		||||
        in_use: set[int] = set(db.session.scalars(
 | 
			
		||||
            sa.select(JournalEntryLineItem.account_id)
 | 
			
		||||
@@ -237,7 +237,7 @@ class JournalEntryForm(FlaskForm):
 | 
			
		||||
        :return: The selectable credit accounts.
 | 
			
		||||
        """
 | 
			
		||||
        accounts: list[AccountOption] \
 | 
			
		||||
            = [AccountOption(x) for x in Account.credit()
 | 
			
		||||
            = [AccountOption(x) for x in Account.selectable_credit()
 | 
			
		||||
               if not (x.code[0] == "1" and x.is_need_offset)]
 | 
			
		||||
        in_use: set[int] = set(db.session.scalars(
 | 
			
		||||
            sa.select(JournalEntryLineItem.account_id)
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,6 @@
 | 
			
		||||
"""The line item sub-forms for the journal entry management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
@@ -26,9 +25,11 @@ 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 wtforms.validators import Optional
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
 | 
			
		||||
    IsCreditAccount
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
from accounting.template_filters import format_amount
 | 
			
		||||
@@ -37,10 +38,6 @@ from accounting.utils.random_id import new_id
 | 
			
		||||
from accounting.utils.strip_text import strip_text
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
 | 
			
		||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
 | 
			
		||||
    lazy_gettext("Please select the account."))
 | 
			
		||||
"""The validator to check if the account code is empty."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OriginalLineItemExists:
 | 
			
		||||
    """The validator to check if the original line item exists."""
 | 
			
		||||
@@ -105,45 +102,6 @@ class OriginalLineItemNotOffset:
 | 
			
		||||
                "The original line item cannot be an offset item."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountExists:
 | 
			
		||||
    """The validator to check if the account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if Account.find_by_code(field.data) is None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The account does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsDebitAccount:
 | 
			
		||||
    """The validator to check if the account is for debit line items."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[1235689]|7[5678])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(lazy_gettext(
 | 
			
		||||
            "This account is not for debit line items."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsCreditAccount:
 | 
			
		||||
    """The validator to check if the account is for credit line items."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[123489]|7[1234])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(lazy_gettext(
 | 
			
		||||
            "This account is not for credit line items."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SameAccountAsOriginalLineItem:
 | 
			
		||||
    """The validator to check if the account is the same as the
 | 
			
		||||
    original line item."""
 | 
			
		||||
@@ -452,12 +410,14 @@ class DebitLineItemForm(LineItemForm):
 | 
			
		||||
    """The ID of the original line item."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[ACCOUNT_REQUIRED,
 | 
			
		||||
                    AccountExists(),
 | 
			
		||||
                    IsDebitAccount(),
 | 
			
		||||
                    SameAccountAsOriginalLineItem(),
 | 
			
		||||
                    KeepAccountWhenHavingOffset(),
 | 
			
		||||
                    NotStartPayableFromDebit()])
 | 
			
		||||
        validators=[
 | 
			
		||||
            ACCOUNT_REQUIRED,
 | 
			
		||||
            AccountExists(),
 | 
			
		||||
            IsDebitAccount(lazy_gettext(
 | 
			
		||||
                "This account is not for debit line items.")),
 | 
			
		||||
            SameAccountAsOriginalLineItem(),
 | 
			
		||||
            KeepAccountWhenHavingOffset(),
 | 
			
		||||
            NotStartPayableFromDebit()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    description = StringField(filters=[strip_text])
 | 
			
		||||
    """The description."""
 | 
			
		||||
@@ -502,12 +462,14 @@ class CreditLineItemForm(LineItemForm):
 | 
			
		||||
    """The ID of the original line item."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[ACCOUNT_REQUIRED,
 | 
			
		||||
                    AccountExists(),
 | 
			
		||||
                    IsCreditAccount(),
 | 
			
		||||
                    SameAccountAsOriginalLineItem(),
 | 
			
		||||
                    KeepAccountWhenHavingOffset(),
 | 
			
		||||
                    NotStartReceivableFromCredit()])
 | 
			
		||||
        validators=[
 | 
			
		||||
            ACCOUNT_REQUIRED,
 | 
			
		||||
            AccountExists(),
 | 
			
		||||
            IsCreditAccount(lazy_gettext(
 | 
			
		||||
                "This account is not for credit line items.")),
 | 
			
		||||
            SameAccountAsOriginalLineItem(),
 | 
			
		||||
            KeepAccountWhenHavingOffset(),
 | 
			
		||||
            NotStartReceivableFromCredit()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    description = StringField(filters=[strip_text])
 | 
			
		||||
    """The description."""
 | 
			
		||||
 
 | 
			
		||||
@@ -17,12 +17,14 @@
 | 
			
		||||
"""The description editor.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import Account, JournalEntryLineItem
 | 
			
		||||
from accounting.utils.options import options, Recurring
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DescriptionAccount:
 | 
			
		||||
@@ -143,6 +145,29 @@ class DescriptionType:
 | 
			
		||||
        return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DescriptionRecurring:
 | 
			
		||||
    """A recurring transaction."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, name: str, account: Account, description_template: str):
 | 
			
		||||
        """Constructs a recurring transaction.
 | 
			
		||||
 | 
			
		||||
        :param name: The name.
 | 
			
		||||
        :param description_template: The description template.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        """
 | 
			
		||||
        self.name: str = name
 | 
			
		||||
        self.account: DescriptionAccount = DescriptionAccount(account, 0)
 | 
			
		||||
        self.description_template: str = description_template
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_codes(self) -> list[str]:
 | 
			
		||||
        """Returns the account codes by the order of their frequencies.
 | 
			
		||||
 | 
			
		||||
        :return: The account codes by the order of their frequencies.
 | 
			
		||||
        """
 | 
			
		||||
        return [self.account.code]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DescriptionDebitCredit:
 | 
			
		||||
    """The description on debit or credit."""
 | 
			
		||||
 | 
			
		||||
@@ -163,6 +188,8 @@ class DescriptionDebitCredit:
 | 
			
		||||
                               DescriptionType] \
 | 
			
		||||
            = {x.id: x for x in {self.general, self.travel, self.bus}}
 | 
			
		||||
        """A dictionary from the type ID to the corresponding tags."""
 | 
			
		||||
        self.recurring: list[DescriptionRecurring] = []
 | 
			
		||||
        """The recurring transactions."""
 | 
			
		||||
 | 
			
		||||
    def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
 | 
			
		||||
                name: str, account: Account, freq: int) -> None:
 | 
			
		||||
@@ -193,6 +220,10 @@ class DescriptionDebitCredit:
 | 
			
		||||
                        freq[account.id] = 0
 | 
			
		||||
                    freq[account.id] \
 | 
			
		||||
                        = freq[account.id] + account.freq
 | 
			
		||||
        for recurring in self.recurring:
 | 
			
		||||
            accounts[recurring.account.id] = recurring.account
 | 
			
		||||
            if recurring.account.id not in freq:
 | 
			
		||||
                freq[recurring.account.id] = 0
 | 
			
		||||
        return [accounts[y] for y in sorted(freq.keys(),
 | 
			
		||||
                                            key=lambda x: -freq[x])]
 | 
			
		||||
 | 
			
		||||
@@ -206,6 +237,14 @@ class DescriptionEditor:
 | 
			
		||||
        """The debit tags."""
 | 
			
		||||
        self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
 | 
			
		||||
        """The credit tags."""
 | 
			
		||||
        self.__init_tags()
 | 
			
		||||
        self.__init_recurring()
 | 
			
		||||
 | 
			
		||||
    def __init_tags(self):
 | 
			
		||||
        """Initializes the tags.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        debit_credit: sa.Label = sa.case(
 | 
			
		||||
            (JournalEntryLineItem.is_debit, "debit"),
 | 
			
		||||
            else_="credit").label("debit_credit")
 | 
			
		||||
@@ -236,6 +275,50 @@ class DescriptionEditor:
 | 
			
		||||
            debit_credit_dict[row.debit_credit].add_tag(
 | 
			
		||||
                row.tag_type, row.tag, accounts[row.account_id], row.freq)
 | 
			
		||||
 | 
			
		||||
    def __init_recurring(self) -> None:
 | 
			
		||||
        """Initializes the recurring transactions.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        recurring: Recurring = options.recurring
 | 
			
		||||
        accounts: dict[str, Account] \
 | 
			
		||||
            = self.__get_accounts(recurring.codes)
 | 
			
		||||
        self.debit.recurring \
 | 
			
		||||
            = [DescriptionRecurring(x.name, accounts[x.account_code],
 | 
			
		||||
                                    x.description_template)
 | 
			
		||||
               for x in recurring.expenses]
 | 
			
		||||
        self.credit.recurring \
 | 
			
		||||
            = [DescriptionRecurring(x.name, accounts[x.account_code],
 | 
			
		||||
                                    x.description_template)
 | 
			
		||||
               for x in recurring.incomes]
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __get_accounts(codes: set[str]) -> dict[str, Account]:
 | 
			
		||||
        """Finds and returns the accounts by codes.
 | 
			
		||||
 | 
			
		||||
        :param codes: The account codes.
 | 
			
		||||
        :return: The account.
 | 
			
		||||
        """
 | 
			
		||||
        if len(codes) == 0:
 | 
			
		||||
            return {}
 | 
			
		||||
 | 
			
		||||
        def get_condition(code0: str) -> sa.BinaryExpression:
 | 
			
		||||
            m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
 | 
			
		||||
            assert m is not None,\
 | 
			
		||||
                f"Malformed account code \"{code0}\" for regular transactions."
 | 
			
		||||
            return sa.and_(Account.base_code == m.group(1),
 | 
			
		||||
                           Account.no == int(m.group(2)))
 | 
			
		||||
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [get_condition(x) for x in codes]
 | 
			
		||||
        accounts: dict[str, Account] \
 | 
			
		||||
            = {x.code: x for x in
 | 
			
		||||
               Account.query.filter(sa.or_(*conditions)).all()}
 | 
			
		||||
        for code in codes:
 | 
			
		||||
            assert code in accounts,\
 | 
			
		||||
                f"Unknown account \"{code}\" for regular transactions."
 | 
			
		||||
        return accounts
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
 | 
			
		||||
        -> sa.Function:
 | 
			
		||||
 
 | 
			
		||||
@@ -175,6 +175,9 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    :return: The redirection to the journal entry list on success, or the
 | 
			
		||||
        journal entry detail on error.
 | 
			
		||||
    """
 | 
			
		||||
    if not journal_entry.can_delete:
 | 
			
		||||
        flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error")
 | 
			
		||||
        return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
			
		||||
    journal_entry.delete()
 | 
			
		||||
    sort_journal_entries_in(journal_entry.date, journal_entry.id)
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
 
 | 
			
		||||
@@ -25,8 +25,8 @@ from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import current_app
 | 
			
		||||
from flask_babel import get_locale
 | 
			
		||||
from babel import Locale
 | 
			
		||||
from flask_babel import get_locale, get_babel
 | 
			
		||||
from sqlalchemy import text
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
@@ -61,11 +61,11 @@ class BaseAccount(db.Model):
 | 
			
		||||
 | 
			
		||||
        :return: The title in the current locale.
 | 
			
		||||
        """
 | 
			
		||||
        current_locale = str(get_locale())
 | 
			
		||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
			
		||||
        current_locale: Locale = get_locale()
 | 
			
		||||
        if current_locale == get_babel().instance.default_locale:
 | 
			
		||||
            return self.title_l10n
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
            if l10n.locale == str(current_locale):
 | 
			
		||||
                return l10n.title
 | 
			
		||||
        return self.title_l10n
 | 
			
		||||
 | 
			
		||||
@@ -171,11 +171,11 @@ class Account(db.Model):
 | 
			
		||||
 | 
			
		||||
        :return: The title in the current locale.
 | 
			
		||||
        """
 | 
			
		||||
        current_locale = str(get_locale())
 | 
			
		||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
			
		||||
        current_locale: Locale = get_locale()
 | 
			
		||||
        if current_locale == get_babel().instance.default_locale:
 | 
			
		||||
            return self.title_l10n
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
            if l10n.locale == str(current_locale):
 | 
			
		||||
                return l10n.title
 | 
			
		||||
        return self.title_l10n
 | 
			
		||||
 | 
			
		||||
@@ -189,15 +189,15 @@ class Account(db.Model):
 | 
			
		||||
        if self.title_l10n is None:
 | 
			
		||||
            self.title_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
        current_locale = str(get_locale())
 | 
			
		||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
			
		||||
        current_locale: Locale = get_locale()
 | 
			
		||||
        if current_locale == get_babel().instance.default_locale:
 | 
			
		||||
            self.title_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
            if l10n.locale == str(current_locale):
 | 
			
		||||
                l10n.title = value
 | 
			
		||||
                return
 | 
			
		||||
        self.l10n.append(AccountL10n(locale=current_locale, title=value))
 | 
			
		||||
        self.l10n.append(AccountL10n(locale=str(current_locale), title=value))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_real(self) -> bool:
 | 
			
		||||
@@ -236,6 +236,16 @@ class Account(db.Model):
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def can_delete(self) -> bool:
 | 
			
		||||
        """Returns whether the account can be deleted.
 | 
			
		||||
 | 
			
		||||
        :return: True if the account can be deleted, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        if self.code in {"1111-001", "3351-001", "3353-001"}:
 | 
			
		||||
            return False
 | 
			
		||||
        return len(self.line_items) == 0
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> None:
 | 
			
		||||
        """Deletes this account.
 | 
			
		||||
 | 
			
		||||
@@ -259,13 +269,15 @@ class Account(db.Model):
 | 
			
		||||
                                cls.no == int(m.group(2))).first()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def debit(cls) -> list[t.Self]:
 | 
			
		||||
        """Returns the debit accounts.
 | 
			
		||||
    def selectable_debit(cls) -> list[t.Self]:
 | 
			
		||||
        """Returns the selectable debit accounts.
 | 
			
		||||
        Payable line items can not start from debit.
 | 
			
		||||
 | 
			
		||||
        :return: The debit accounts.
 | 
			
		||||
        :return: The selectable debit accounts.
 | 
			
		||||
        """
 | 
			
		||||
        return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
 | 
			
		||||
                                       cls.base_code.startswith("2"),
 | 
			
		||||
                                       sa.and_(cls.base_code.startswith("2"),
 | 
			
		||||
                                               sa.not_(cls.is_need_offset)),
 | 
			
		||||
                                       cls.base_code.startswith("3"),
 | 
			
		||||
                                       cls.base_code.startswith("5"),
 | 
			
		||||
                                       cls.base_code.startswith("6"),
 | 
			
		||||
@@ -280,12 +292,14 @@ class Account(db.Model):
 | 
			
		||||
            .order_by(cls.base_code, cls.no).all()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def credit(cls) -> list[t.Self]:
 | 
			
		||||
        """Returns the debit accounts.
 | 
			
		||||
    def selectable_credit(cls) -> list[t.Self]:
 | 
			
		||||
        """Returns the selectable debit accounts.
 | 
			
		||||
        Receivable line items can not start from credit.
 | 
			
		||||
 | 
			
		||||
        :return: The debit accounts.
 | 
			
		||||
        :return: The selectable debit accounts.
 | 
			
		||||
        """
 | 
			
		||||
        return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
 | 
			
		||||
        return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
 | 
			
		||||
                                               sa.not_(cls.is_need_offset)),
 | 
			
		||||
                                       cls.base_code.startswith("2"),
 | 
			
		||||
                                       cls.base_code.startswith("3"),
 | 
			
		||||
                                       cls.base_code.startswith("4"),
 | 
			
		||||
@@ -381,11 +395,11 @@ class Currency(db.Model):
 | 
			
		||||
 | 
			
		||||
        :return: The name in the current locale.
 | 
			
		||||
        """
 | 
			
		||||
        current_locale = str(get_locale())
 | 
			
		||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
			
		||||
        current_locale: Locale = get_locale()
 | 
			
		||||
        if current_locale == get_babel().instance.default_locale:
 | 
			
		||||
            return self.name_l10n
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
            if l10n.locale == str(current_locale):
 | 
			
		||||
                return l10n.name
 | 
			
		||||
        return self.name_l10n
 | 
			
		||||
 | 
			
		||||
@@ -399,15 +413,15 @@ class Currency(db.Model):
 | 
			
		||||
        if self.name_l10n is None:
 | 
			
		||||
            self.name_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
        current_locale = str(get_locale())
 | 
			
		||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
			
		||||
        current_locale: Locale = get_locale()
 | 
			
		||||
        if current_locale == get_babel().instance.default_locale:
 | 
			
		||||
            self.name_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
            if l10n.locale == str(current_locale):
 | 
			
		||||
                l10n.name = value
 | 
			
		||||
                return
 | 
			
		||||
        self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
 | 
			
		||||
        self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_modified(self) -> bool:
 | 
			
		||||
@@ -422,6 +436,17 @@ class Currency(db.Model):
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def can_delete(self) -> bool:
 | 
			
		||||
        """Returns whether the currency can be deleted.
 | 
			
		||||
 | 
			
		||||
        :return: True if the currency can be deleted, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.template_globals import default_currency_code
 | 
			
		||||
        if self.code == default_currency_code():
 | 
			
		||||
            return False
 | 
			
		||||
        return len(self.line_items) == 0
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> None:
 | 
			
		||||
        """Deletes the currency.
 | 
			
		||||
 | 
			
		||||
@@ -597,14 +622,10 @@ class JournalEntry(db.Model):
 | 
			
		||||
 | 
			
		||||
        :return: True if the journal entry can be deleted, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        if not hasattr(self, "__can_delete"):
 | 
			
		||||
            def has_offset() -> bool:
 | 
			
		||||
                for line_item in self.line_items:
 | 
			
		||||
                    if len(line_item.offsets) > 0:
 | 
			
		||||
                        return True
 | 
			
		||||
        for line_item in self.line_items:
 | 
			
		||||
            if len(line_item.offsets) > 0:
 | 
			
		||||
                return False
 | 
			
		||||
            setattr(self, "__can_delete", not has_offset())
 | 
			
		||||
        return getattr(self, "__can_delete")
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> None:
 | 
			
		||||
        """Deletes the journal entry.
 | 
			
		||||
@@ -739,7 +760,7 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        setattr(self, "__net_balance", net_balance)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def query_values(self) -> tuple[list[str], list[str]]:
 | 
			
		||||
    def query_values(self) -> list[str]:
 | 
			
		||||
        """Returns the values to be queried.
 | 
			
		||||
 | 
			
		||||
        :return: The values to be queried.
 | 
			
		||||
@@ -751,14 +772,43 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
 | 
			
		||||
        journal_entry_day: date = self.journal_entry.date
 | 
			
		||||
        description: str = "" if self.description is None else self.description
 | 
			
		||||
        return ([description],
 | 
			
		||||
                [str(journal_entry_day.year),
 | 
			
		||||
                 "{}/{}".format(journal_entry_day.year,
 | 
			
		||||
                                journal_entry_day.month),
 | 
			
		||||
                 "{}/{}".format(journal_entry_day.month,
 | 
			
		||||
                                journal_entry_day.day),
 | 
			
		||||
                 "{}/{}/{}".format(journal_entry_day.year,
 | 
			
		||||
                                   journal_entry_day.month,
 | 
			
		||||
                                   journal_entry_day.day),
 | 
			
		||||
                 format_amount(self.amount),
 | 
			
		||||
                 format_amount(self.net_balance)])
 | 
			
		||||
        return [description,
 | 
			
		||||
                str(journal_entry_day.year),
 | 
			
		||||
                "{}/{}".format(journal_entry_day.year,
 | 
			
		||||
                               journal_entry_day.month),
 | 
			
		||||
                "{}/{}".format(journal_entry_day.month,
 | 
			
		||||
                               journal_entry_day.day),
 | 
			
		||||
                "{}/{}/{}".format(journal_entry_day.year,
 | 
			
		||||
                                  journal_entry_day.month,
 | 
			
		||||
                                  journal_entry_day.day),
 | 
			
		||||
                format_amount(self.amount)]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Option(db.Model):
 | 
			
		||||
    """An option."""
 | 
			
		||||
    __tablename__ = "accounting_options"
 | 
			
		||||
    """The table name."""
 | 
			
		||||
    name = db.Column(db.String, nullable=False, primary_key=True)
 | 
			
		||||
    """The name."""
 | 
			
		||||
    value = db.Column(db.Text, nullable=False)
 | 
			
		||||
    """The option value."""
 | 
			
		||||
    created_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
			
		||||
                           server_default=db.func.now())
 | 
			
		||||
    """The time of creation."""
 | 
			
		||||
    created_by_id = db.Column(db.Integer,
 | 
			
		||||
                              db.ForeignKey(user_pk_column,
 | 
			
		||||
                                            onupdate="CASCADE"),
 | 
			
		||||
                              nullable=False)
 | 
			
		||||
    """The ID of the creator."""
 | 
			
		||||
    created_by = db.relationship(user_cls, foreign_keys=created_by_id)
 | 
			
		||||
    """The creator."""
 | 
			
		||||
    updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
			
		||||
                           server_default=db.func.now())
 | 
			
		||||
    """The time of last update."""
 | 
			
		||||
    updated_by_id = db.Column(db.Integer,
 | 
			
		||||
                              db.ForeignKey(user_pk_column,
 | 
			
		||||
                                            onupdate="CASCADE"),
 | 
			
		||||
                              nullable=False)
 | 
			
		||||
    """The ID of the updator."""
 | 
			
		||||
    updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
 | 
			
		||||
    """The updator."""
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										30
									
								
								src/accounting/option/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/accounting/option/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
 | 
			
		||||
 | 
			
		||||
#  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 option management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from flask import Blueprint
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(bp: Blueprint) -> None:
 | 
			
		||||
    """Initialize the application.
 | 
			
		||||
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    from .views import bp as option_bp
 | 
			
		||||
    bp.register_blueprint(option_bp, url_prefix="/options")
 | 
			
		||||
							
								
								
									
										269
									
								
								src/accounting/option/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								src/accounting/option/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,269 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
 | 
			
		||||
 | 
			
		||||
#  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 option management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from flask import render_template
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField, FieldList, FormField, IntegerField
 | 
			
		||||
from wtforms.validators import DataRequired, ValidationError
 | 
			
		||||
 | 
			
		||||
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
 | 
			
		||||
    IsDebitAccount, IsCreditAccount
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from accounting.utils.options import Options
 | 
			
		||||
from accounting.utils.strip_text import strip_text
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrentAccountExists:
 | 
			
		||||
    """The validator to check that the current account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return
 | 
			
		||||
        if Account.find_by_code(field.data) is None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The account does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountNotCurrent:
 | 
			
		||||
    """The validator to check that the account is a current account."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return
 | 
			
		||||
        if field.data[:2] not in {"11", "12", "21", "22"}:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "This is not a current account."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NotStartPayableFromExpense:
 | 
			
		||||
    """The validator to check that a payable line item does not start from
 | 
			
		||||
    expense."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None or field.data[0] != "2":
 | 
			
		||||
            return
 | 
			
		||||
        account: Account | None = Account.find_by_code(field.data)
 | 
			
		||||
        if account is not None and account.is_need_offset:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "You cannot select a payable account as expense."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NotStartReceivableFromIncome:
 | 
			
		||||
    """The validator to check that a receivable line item does not start
 | 
			
		||||
    from income."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None or field.data[0] != "1":
 | 
			
		||||
            return
 | 
			
		||||
        account: Account | None = Account.find_by_code(field.data)
 | 
			
		||||
        if account is not None and account.is_need_offset:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "You cannot select a receivable account as income."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RecurringItemForm(FlaskForm):
 | 
			
		||||
    """The base sub-form to add or update the recurring item."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order number of this recurring item."""
 | 
			
		||||
    name = StringField()
 | 
			
		||||
    """The name of the recurring item."""
 | 
			
		||||
    account_code = StringField()
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    description_template = StringField()
 | 
			
		||||
    """The description template."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_text(self) -> str | None:
 | 
			
		||||
        """Returns the account text.
 | 
			
		||||
 | 
			
		||||
        :return: The account text.
 | 
			
		||||
        """
 | 
			
		||||
        if self.account_code.data is None:
 | 
			
		||||
            return None
 | 
			
		||||
        account: Account | None = Account.find_by_code(self.account_code.data)
 | 
			
		||||
        return None if account is None else 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 RecurringExpenseForm(RecurringItemForm):
 | 
			
		||||
    """The sub-form to add or update the recurring expenses."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order number of this recurring item."""
 | 
			
		||||
    name = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(lazy_gettext("Please fill in the name."))])
 | 
			
		||||
    """The name of the recurring item."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[
 | 
			
		||||
            ACCOUNT_REQUIRED,
 | 
			
		||||
            AccountExists(),
 | 
			
		||||
            IsDebitAccount(lazy_gettext("This account is not for expense.")),
 | 
			
		||||
            NotStartPayableFromExpense()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    description_template = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[
 | 
			
		||||
            DataRequired(lazy_gettext(
 | 
			
		||||
                "Please fill in the description template."))])
 | 
			
		||||
    """The template for the line item description."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RecurringIncomeForm(RecurringItemForm):
 | 
			
		||||
    """The sub-form to add or update the recurring incomes."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order number of this recurring item."""
 | 
			
		||||
    name = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(lazy_gettext("Please fill in the name."))])
 | 
			
		||||
    """The name of the recurring item."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[
 | 
			
		||||
            ACCOUNT_REQUIRED,
 | 
			
		||||
            AccountExists(),
 | 
			
		||||
            IsCreditAccount(lazy_gettext("This account is not for income.")),
 | 
			
		||||
            NotStartReceivableFromIncome()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    description_template = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[
 | 
			
		||||
            DataRequired(lazy_gettext(
 | 
			
		||||
                "Please fill in the description template."))])
 | 
			
		||||
    """The description template."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RecurringForm(RecurringItemForm):
 | 
			
		||||
    """The sub-form for the recurring expenses and incomes."""
 | 
			
		||||
    expenses = FieldList(FormField(RecurringExpenseForm), name="expense")
 | 
			
		||||
    """The recurring expenses."""
 | 
			
		||||
    incomes = FieldList(FormField(RecurringIncomeForm), name="income")
 | 
			
		||||
    """The recurring incomes."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def item_template(self) -> str:
 | 
			
		||||
        """Returns the template of a recurring item.
 | 
			
		||||
 | 
			
		||||
        :return: The template of a recurring item.
 | 
			
		||||
        """
 | 
			
		||||
        return render_template(
 | 
			
		||||
            "accounting/option/include/form-recurring-item.html",
 | 
			
		||||
            expense_income="EXPENSE_INCOME",
 | 
			
		||||
            item_index="ITEM_INDEX",
 | 
			
		||||
            form=RecurringItemForm())
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def expense_accounts(self) -> list[Account]:
 | 
			
		||||
        """The expense accounts.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        return Account.selectable_debit()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def income_accounts(self) -> list[Account]:
 | 
			
		||||
        """The income accounts.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        return Account.selectable_credit()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def as_data(self) -> dict[str, list[tuple[str, str, str]]]:
 | 
			
		||||
        """Returns the form data.
 | 
			
		||||
 | 
			
		||||
        :return: The form data.
 | 
			
		||||
        """
 | 
			
		||||
        def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]:
 | 
			
		||||
            return (item.name.data, item.account_code.data,
 | 
			
		||||
                    item.description_template.data)
 | 
			
		||||
 | 
			
		||||
        expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
 | 
			
		||||
        self.__sort_item_forms(expenses)
 | 
			
		||||
        incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
 | 
			
		||||
        self.__sort_item_forms(incomes)
 | 
			
		||||
        return {"expense": [as_tuple(x) for x in expenses],
 | 
			
		||||
                "income": [as_tuple(x) for x in incomes]}
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
 | 
			
		||||
        """Sorts the recurring item sub-forms.
 | 
			
		||||
 | 
			
		||||
        :param forms: The recurring item sub-forms.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        ord_by_form: dict[RecurringItemForm, int] \
 | 
			
		||||
            = {forms[i]: i for i in range(len(forms))}
 | 
			
		||||
        recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
 | 
			
		||||
        missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
 | 
			
		||||
        forms.sort(key=lambda x: (x.no.data or missing_recv_no,
 | 
			
		||||
                                  ord_by_form.get(x)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OptionForm(FlaskForm):
 | 
			
		||||
    """The form to update the options."""
 | 
			
		||||
    default_currency_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[
 | 
			
		||||
            DataRequired(lazy_gettext("Please select the default currency.")),
 | 
			
		||||
            CurrencyExists()])
 | 
			
		||||
    """The default currency code."""
 | 
			
		||||
    default_ie_account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[
 | 
			
		||||
            DataRequired(lazy_gettext(
 | 
			
		||||
                "Please select the default account"
 | 
			
		||||
                " for the income and expenses log.")),
 | 
			
		||||
            CurrentAccountExists(),
 | 
			
		||||
            AccountNotCurrent()])
 | 
			
		||||
    """The default account code for the income and expenses log."""
 | 
			
		||||
    recurring = FormField(RecurringForm)
 | 
			
		||||
    """The recurring expenses and incomes."""
 | 
			
		||||
 | 
			
		||||
    def populate_obj(self, obj: Options) -> None:
 | 
			
		||||
        """Populates the form data into a currency object.
 | 
			
		||||
 | 
			
		||||
        :param obj: The currency object.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        obj.default_currency_code = self.default_currency_code.data
 | 
			
		||||
        obj.default_ie_account_code = self.default_ie_account_code.data
 | 
			
		||||
        obj.recurring_data = self.recurring.form.as_data
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def current_accounts(self) -> list[CurrentAccount]:
 | 
			
		||||
        """Returns the current accounts.
 | 
			
		||||
 | 
			
		||||
        :return: The current accounts.
 | 
			
		||||
        """
 | 
			
		||||
        return CurrentAccount.accounts()
 | 
			
		||||
							
								
								
									
										83
									
								
								src/accounting/option/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/accounting/option/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,83 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The views for the option management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from urllib.parse import parse_qsl, urlencode
 | 
			
		||||
 | 
			
		||||
from flask import Blueprint, render_template, redirect, session, request, \
 | 
			
		||||
    flash, url_for
 | 
			
		||||
from werkzeug.datastructures import ImmutableMultiDict
 | 
			
		||||
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.utils.cast import s
 | 
			
		||||
from accounting.utils.flash_errors import flash_form_errors
 | 
			
		||||
from accounting.utils.next_uri import inherit_next
 | 
			
		||||
from accounting.utils.options import options
 | 
			
		||||
from accounting.utils.permission import has_permission, can_admin
 | 
			
		||||
from .forms import OptionForm
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("option", __name__)
 | 
			
		||||
"""The view blueprint for the currency management."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("", endpoint="detail")
 | 
			
		||||
@has_permission(can_admin)
 | 
			
		||||
def show_options() -> str:
 | 
			
		||||
    """Shows the options.
 | 
			
		||||
 | 
			
		||||
    :return: The options.
 | 
			
		||||
    """
 | 
			
		||||
    return render_template("accounting/option/detail.html", obj=options)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("edit", endpoint="edit")
 | 
			
		||||
@has_permission(can_admin)
 | 
			
		||||
def show_option_form() -> str:
 | 
			
		||||
    """Shows the option form.
 | 
			
		||||
 | 
			
		||||
    :return: The option form.
 | 
			
		||||
    """
 | 
			
		||||
    form: OptionForm
 | 
			
		||||
    if "form" in session:
 | 
			
		||||
        form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"])))
 | 
			
		||||
        del session["form"]
 | 
			
		||||
        form.validate()
 | 
			
		||||
    else:
 | 
			
		||||
        form = OptionForm(obj=options)
 | 
			
		||||
    return render_template("accounting/option/form.html", form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("update", endpoint="update")
 | 
			
		||||
@has_permission(can_admin)
 | 
			
		||||
def update_options() -> redirect:
 | 
			
		||||
    """Updates the options.
 | 
			
		||||
 | 
			
		||||
    :return: The redirection to the option form.
 | 
			
		||||
    """
 | 
			
		||||
    form = OptionForm(request.form)
 | 
			
		||||
    if not form.validate():
 | 
			
		||||
        flash_form_errors(form)
 | 
			
		||||
        session["form"] = urlencode(list(request.form.items()))
 | 
			
		||||
        return redirect(inherit_next(url_for("accounting.option.edit")))
 | 
			
		||||
    form.populate_obj(options)
 | 
			
		||||
    if not options.is_modified:
 | 
			
		||||
        flash(s(lazy_gettext("The settings were not modified.")), "success")
 | 
			
		||||
        return redirect(inherit_next(url_for("accounting.option.detail")))
 | 
			
		||||
    options.commit()
 | 
			
		||||
    flash(s(lazy_gettext("The settings are saved successfully.")), "success")
 | 
			
		||||
    return redirect(inherit_next(url_for("accounting.option.detail")))
 | 
			
		||||
@@ -23,8 +23,8 @@ from flask import abort
 | 
			
		||||
from werkzeug.routing import BaseConverter
 | 
			
		||||
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from .period import Period, get_period
 | 
			
		||||
from .utils.ie_account import IncomeExpensesAccount
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PeriodConverter(BaseConverter):
 | 
			
		||||
@@ -55,22 +55,22 @@ class IncomeExpensesAccountConverter(BaseConverter):
 | 
			
		||||
    """The supplier converter to convert the income and expenses log pseudo
 | 
			
		||||
    account code from and to the corresponding pseudo account in the routes."""
 | 
			
		||||
 | 
			
		||||
    def to_python(self, value: str) -> IncomeExpensesAccount:
 | 
			
		||||
    def to_python(self, value: str) -> CurrentAccount:
 | 
			
		||||
        """Converts an account code to an account.
 | 
			
		||||
 | 
			
		||||
        :param value: The account code.
 | 
			
		||||
        :return: The corresponding account.
 | 
			
		||||
        """
 | 
			
		||||
        if value == IncomeExpensesAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return IncomeExpensesAccount.current_assets_and_liabilities()
 | 
			
		||||
        if value == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return CurrentAccount.current_assets_and_liabilities()
 | 
			
		||||
        if not re.match("^[12][12]", value):
 | 
			
		||||
            abort(404)
 | 
			
		||||
        account: Account | None = Account.find_by_code(value)
 | 
			
		||||
        if account is None:
 | 
			
		||||
            abort(404)
 | 
			
		||||
        return IncomeExpensesAccount(account)
 | 
			
		||||
        return CurrentAccount(account)
 | 
			
		||||
 | 
			
		||||
    def to_url(self, value: IncomeExpensesAccount) -> str:
 | 
			
		||||
    def to_url(self, value: CurrentAccount) -> str:
 | 
			
		||||
        """Converts an account to account code.
 | 
			
		||||
 | 
			
		||||
        :param value: The account.
 | 
			
		||||
 
 | 
			
		||||
@@ -33,12 +33,12 @@ from accounting.report.utils.base_page_params import BasePageParams
 | 
			
		||||
from accounting.report.utils.base_report import BaseReport
 | 
			
		||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
 | 
			
		||||
    period_spec
 | 
			
		||||
from accounting.report.utils.ie_account import IncomeExpensesAccount
 | 
			
		||||
from accounting.report.utils.option_link import OptionLink
 | 
			
		||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
			
		||||
from accounting.report.utils.report_type import ReportType
 | 
			
		||||
from accounting.report.utils.urls import income_expenses_url
 | 
			
		||||
from accounting.utils.cast import be
 | 
			
		||||
from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from accounting.utils.pagination import Pagination
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -84,7 +84,7 @@ class ReportLineItem:
 | 
			
		||||
class LineItemCollector:
 | 
			
		||||
    """The line item collector."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
    def __init__(self, currency: Currency, account: CurrentAccount,
 | 
			
		||||
                 period: Period):
 | 
			
		||||
        """Constructs the line item collector.
 | 
			
		||||
 | 
			
		||||
@@ -94,7 +94,7 @@ class LineItemCollector:
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__account: IncomeExpensesAccount = account
 | 
			
		||||
        self.__account: CurrentAccount = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period"""
 | 
			
		||||
@@ -173,11 +173,8 @@ class LineItemCollector:
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def __account_condition(self) -> sa.BinaryExpression:
 | 
			
		||||
        if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return sa.or_(Account.base_code.startswith("11"),
 | 
			
		||||
                          Account.base_code.startswith("12"),
 | 
			
		||||
                          Account.base_code.startswith("21"),
 | 
			
		||||
                          Account.base_code.startswith("22"))
 | 
			
		||||
        if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return CurrentAccount.sql_condition()
 | 
			
		||||
        return Account.id == self.__account.id
 | 
			
		||||
 | 
			
		||||
    def __get_total(self) -> ReportLineItem | None:
 | 
			
		||||
@@ -264,7 +261,7 @@ class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency,
 | 
			
		||||
                 account: IncomeExpensesAccount,
 | 
			
		||||
                 account: CurrentAccount,
 | 
			
		||||
                 period: Period,
 | 
			
		||||
                 has_data: bool,
 | 
			
		||||
                 pagination: Pagination[ReportLineItem],
 | 
			
		||||
@@ -283,7 +280,7 @@ class PageParams(BasePageParams):
 | 
			
		||||
        """
 | 
			
		||||
        self.currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.account: IncomeExpensesAccount = account
 | 
			
		||||
        self.account: CurrentAccount = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
@@ -341,8 +338,8 @@ class PageParams(BasePageParams):
 | 
			
		||||
 | 
			
		||||
        :return: The account options.
 | 
			
		||||
        """
 | 
			
		||||
        current_al: IncomeExpensesAccount \
 | 
			
		||||
            = IncomeExpensesAccount.current_assets_and_liabilities()
 | 
			
		||||
        current_al: CurrentAccount \
 | 
			
		||||
            = CurrentAccount.current_assets_and_liabilities()
 | 
			
		||||
        options: list[OptionLink] \
 | 
			
		||||
            = [OptionLink(str(current_al),
 | 
			
		||||
                          income_expenses_url(self.currency, current_al,
 | 
			
		||||
@@ -352,15 +349,12 @@ class PageParams(BasePageParams):
 | 
			
		||||
            .join(Account)\
 | 
			
		||||
            .filter(be(JournalEntryLineItem.currency_code
 | 
			
		||||
                       == self.currency.code),
 | 
			
		||||
                    sa.or_(Account.base_code.startswith("11"),
 | 
			
		||||
                           Account.base_code.startswith("12"),
 | 
			
		||||
                           Account.base_code.startswith("21"),
 | 
			
		||||
                           Account.base_code.startswith("22")))\
 | 
			
		||||
                    CurrentAccount.sql_condition())\
 | 
			
		||||
            .group_by(JournalEntryLineItem.account_id)
 | 
			
		||||
        options.extend([OptionLink(str(x),
 | 
			
		||||
                                   income_expenses_url(
 | 
			
		||||
                                       self.currency,
 | 
			
		||||
                                       IncomeExpensesAccount(x),
 | 
			
		||||
                                       CurrentAccount(x),
 | 
			
		||||
                                       self.period),
 | 
			
		||||
                                   x.id == self.account.id)
 | 
			
		||||
                        for x in Account.query.filter(Account.id.in_(in_use))
 | 
			
		||||
@@ -371,7 +365,7 @@ class PageParams(BasePageParams):
 | 
			
		||||
class IncomeExpenses(BaseReport):
 | 
			
		||||
    """The income and expenses log."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
    def __init__(self, currency: Currency, account: CurrentAccount,
 | 
			
		||||
                 period: Period):
 | 
			
		||||
        """Constructs an income and expenses log.
 | 
			
		||||
 | 
			
		||||
@@ -381,7 +375,7 @@ class IncomeExpenses(BaseReport):
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__account: IncomeExpensesAccount = account
 | 
			
		||||
        self.__account: CurrentAccount = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,7 @@ from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Currency, Account
 | 
			
		||||
from accounting.report.period import Period, get_period
 | 
			
		||||
from accounting.template_globals import default_currency_code
 | 
			
		||||
from .ie_account import IncomeExpensesAccount
 | 
			
		||||
from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from .option_link import OptionLink
 | 
			
		||||
from .report_type import ReportType
 | 
			
		||||
from .urls import journal_url, ledger_url, income_expenses_url, \
 | 
			
		||||
@@ -113,7 +113,7 @@ class ReportChooser:
 | 
			
		||||
            account: Account = Account.cash()
 | 
			
		||||
        return OptionLink(gettext("Income and Expenses Log"),
 | 
			
		||||
                          income_expenses_url(self.__currency,
 | 
			
		||||
                                              IncomeExpensesAccount(account),
 | 
			
		||||
                                              CurrentAccount(account),
 | 
			
		||||
                                              self.__period),
 | 
			
		||||
                          self.__active_report == ReportType.INCOME_EXPENSES,
 | 
			
		||||
                          fa_icon="fa-solid fa-money-bill-wave")
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,8 @@ from flask import url_for
 | 
			
		||||
from accounting.models import Currency, Account
 | 
			
		||||
from accounting.report.period import Period
 | 
			
		||||
from accounting.template_globals import default_currency_code
 | 
			
		||||
from .ie_account import IncomeExpensesAccount, default_ie_account_code
 | 
			
		||||
from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from accounting.utils.options import options
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def journal_url(period: Period) \
 | 
			
		||||
@@ -54,7 +55,7 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
                   period=period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
def income_expenses_url(currency: Currency, account: CurrentAccount,
 | 
			
		||||
                        period: Period) -> str:
 | 
			
		||||
    """Returns the URL of an income and expenses log.
 | 
			
		||||
 | 
			
		||||
@@ -64,7 +65,7 @@ def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
    :return: The URL of the income and expenses log.
 | 
			
		||||
    """
 | 
			
		||||
    if currency.code == default_currency_code() \
 | 
			
		||||
            and account.code == default_ie_account_code() \
 | 
			
		||||
            and account.code == options.default_ie_account_code \
 | 
			
		||||
            and period.is_default:
 | 
			
		||||
        return url_for("accounting.report.default")
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
 
 | 
			
		||||
@@ -23,11 +23,12 @@ from accounting import db
 | 
			
		||||
from accounting.models import Currency, Account
 | 
			
		||||
from accounting.report.period import Period, get_period
 | 
			
		||||
from accounting.template_globals import default_currency_code
 | 
			
		||||
from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from accounting.utils.options import options
 | 
			
		||||
from accounting.utils.permission import has_permission, can_view
 | 
			
		||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
 | 
			
		||||
    IncomeStatement, BalanceSheet, Search
 | 
			
		||||
from .template_filters import format_amount
 | 
			
		||||
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("report", __name__)
 | 
			
		||||
"""The view blueprint for the reports."""
 | 
			
		||||
@@ -43,7 +44,7 @@ def get_default_report() -> str | Response:
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_expenses(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()),
 | 
			
		||||
        default_ie_account(),
 | 
			
		||||
        options.default_ie_account,
 | 
			
		||||
        get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -126,8 +127,7 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
 | 
			
		||||
        endpoint="income-expenses-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_income_expenses(currency: Currency,
 | 
			
		||||
                                account: IncomeExpensesAccount) \
 | 
			
		||||
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log in the default period.
 | 
			
		||||
 | 
			
		||||
@@ -142,7 +142,7 @@ def get_default_income_expenses(currency: Currency,
 | 
			
		||||
    "income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
 | 
			
		||||
    endpoint="income-expenses")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
def get_income_expenses(currency: Currency, account: CurrentAccount,
 | 
			
		||||
                        period: Period) -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log.
 | 
			
		||||
 | 
			
		||||
@@ -154,7 +154,7 @@ def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
    return __get_income_expenses(currency, account, period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
def __get_income_expenses(currency: Currency, account: CurrentAccount,
 | 
			
		||||
                          period: Period) -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -76,7 +76,7 @@
 | 
			
		||||
        height: 3.2rem;
 | 
			
		||||
        width: 3.2rem;
 | 
			
		||||
        border-radius: 50%;
 | 
			
		||||
        margin-left: 1rem;
 | 
			
		||||
        margin-right: 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
    .accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
 | 
			
		||||
        padding-top: 0.7rem;
 | 
			
		||||
@@ -315,6 +315,30 @@ a.accounting-report-table-row {
 | 
			
		||||
    padding-left: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The description editor */
 | 
			
		||||
.accounting-description-editor-buttons .btn {
 | 
			
		||||
    margin-bottom: 0.3rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The order of the journal entries in a same day */
 | 
			
		||||
.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
 | 
			
		||||
    color: inherit;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
.accounting-journal-entry-order-item-currency {
 | 
			
		||||
    margin-left: 0.5rem;
 | 
			
		||||
    border-top: thin solid lightgray;
 | 
			
		||||
    margin-top: 0.2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The illustration of the description template for the recurring transactions */
 | 
			
		||||
.accounting-recurring-description-template-illustration p {
 | 
			
		||||
    margin: 0.2rem 0;
 | 
			
		||||
}
 | 
			
		||||
.accounting-recurring-description-template-illustration ul {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The Material Design text field (floating form control in Bootstrap) */
 | 
			
		||||
.accounting-material-text-field {
 | 
			
		||||
    position: relative;
 | 
			
		||||
@@ -339,7 +363,7 @@ a.accounting-report-table-row {
 | 
			
		||||
.accounting-material-fab {
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    right: 2rem;
 | 
			
		||||
    bottom: 1rem;
 | 
			
		||||
    bottom: 2rem;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    flex-direction: column-reverse;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -110,14 +110,23 @@ class AccountForm {
 | 
			
		||||
        this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
 | 
			
		||||
        this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
 | 
			
		||||
        this.#formElement.onsubmit = () => {
 | 
			
		||||
            return this.#validateForm();
 | 
			
		||||
            return this.#validate();
 | 
			
		||||
        };
 | 
			
		||||
        this.#baseControl.onclick = () => {
 | 
			
		||||
            this.#baseControl.classList.add("accounting-not-empty");
 | 
			
		||||
            this.#baseAccountSelector.onOpen(this.#baseCode.value);
 | 
			
		||||
            this.#baseAccountSelector.onOpen();
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the base code.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null}
 | 
			
		||||
     */
 | 
			
		||||
    get baseCode() {
 | 
			
		||||
        return this.#baseCode.value === ""? null: this.#baseCode.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the base account selector is closed.
 | 
			
		||||
     *
 | 
			
		||||
@@ -129,15 +138,14 @@ class AccountForm {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets the base account.
 | 
			
		||||
     * Saves the selected base account.
 | 
			
		||||
     *
 | 
			
		||||
     * @param code {string} the base account code
 | 
			
		||||
     * @param text {string} the text for the base account
 | 
			
		||||
     * @param account {BaseAccountOption} the selected base account
 | 
			
		||||
     */
 | 
			
		||||
    setBaseAccount(code, text) {
 | 
			
		||||
        this.#baseCode.value = code;
 | 
			
		||||
        this.#base.innerText = text;
 | 
			
		||||
        if (["1", "2", "3"].includes(code.substring(0, 1))) {
 | 
			
		||||
    saveBaseAccount(account) {
 | 
			
		||||
        this.#baseCode.value = account.code;
 | 
			
		||||
        this.#base.innerText = account.text;
 | 
			
		||||
        if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
 | 
			
		||||
            this.#isNeedOffsetControl.classList.remove("d-none");
 | 
			
		||||
            this.#isNeedOffset.disabled = false;
 | 
			
		||||
        } else {
 | 
			
		||||
@@ -163,7 +171,7 @@ class AccountForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateForm() {
 | 
			
		||||
    #validate() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = this.#validateBase() && isValid;
 | 
			
		||||
        isValid = this.#validateTitle() && isValid;
 | 
			
		||||
@@ -225,7 +233,7 @@ class BaseAccountSelector {
 | 
			
		||||
     * The account form
 | 
			
		||||
     * @type {AccountForm}
 | 
			
		||||
     */
 | 
			
		||||
    #form;
 | 
			
		||||
    form;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The selector modal
 | 
			
		||||
@@ -253,7 +261,7 @@ class BaseAccountSelector {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The options
 | 
			
		||||
     * @type {HTMLLIElement[]}
 | 
			
		||||
     * @type {BaseAccountOption[]}
 | 
			
		||||
     */
 | 
			
		||||
    #options;
 | 
			
		||||
 | 
			
		||||
@@ -269,83 +277,54 @@ class BaseAccountSelector {
 | 
			
		||||
     * @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);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        this.#clearButton.onclick = () => {
 | 
			
		||||
            this.#form.clearBaseAccount();
 | 
			
		||||
        };
 | 
			
		||||
        this.#initializeBaseAccountQuery();
 | 
			
		||||
        this.form = form;
 | 
			
		||||
        const prefix = "accounting-base-selector";
 | 
			
		||||
        this.#modal = document.getElementById(`${prefix}-modal`);
 | 
			
		||||
        this.#query = document.getElementById(`${prefix}-query`);
 | 
			
		||||
        this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
 | 
			
		||||
        this.#optionList = document.getElementById(`${prefix}-option-list`);
 | 
			
		||||
        this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
 | 
			
		||||
        this.#clearButton = document.getElementById(`${prefix}-clear`);
 | 
			
		||||
 | 
			
		||||
        this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
 | 
			
		||||
        this.#query.oninput = () => this.#filterOptions();
 | 
			
		||||
        this.#clearButton.onclick = () => this.form.clearBaseAccount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the query.
 | 
			
		||||
     * Filters the options.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeBaseAccountQuery() {
 | 
			
		||||
        this.#query.addEventListener("input", () => {
 | 
			
		||||
            if (this.#query.value === "") {
 | 
			
		||||
                for (const option of this.#options) {
 | 
			
		||||
                    option.classList.remove("d-none");
 | 
			
		||||
                }
 | 
			
		||||
                this.#optionList.classList.remove("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.add("d-none");
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
            let hasAnyMatched = false;
 | 
			
		||||
            for (const option of this.#options) {
 | 
			
		||||
                const queryValues = JSON.parse(option.dataset.queryValues);
 | 
			
		||||
                let isMatched = false;
 | 
			
		||||
                for (const queryValue of queryValues) {
 | 
			
		||||
                    if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
 | 
			
		||||
                        isMatched = true;
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if (isMatched) {
 | 
			
		||||
                    option.classList.remove("d-none");
 | 
			
		||||
                    hasAnyMatched = true;
 | 
			
		||||
                } else {
 | 
			
		||||
                    option.classList.add("d-none");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (!hasAnyMatched) {
 | 
			
		||||
                this.#optionList.classList.add("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.remove("d-none");
 | 
			
		||||
    #filterOptions() {
 | 
			
		||||
        let isAnyMatched = false;
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.isMatched(this.#query.value)) {
 | 
			
		||||
                option.setShown(true);
 | 
			
		||||
                isAnyMatched = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                this.#optionList.classList.remove("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.add("d-none");
 | 
			
		||||
                option.setShown(false);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        }
 | 
			
		||||
        if (!isAnyMatched) {
 | 
			
		||||
            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 base account selector is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param baseCode {string} the active base code
 | 
			
		||||
     */
 | 
			
		||||
    onOpen(baseCode) {
 | 
			
		||||
    onOpen() {
 | 
			
		||||
        this.#query.value = "";
 | 
			
		||||
        this.#filterOptions();
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.dataset.code === baseCode) {
 | 
			
		||||
                option.classList.add("active");
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.remove("active");
 | 
			
		||||
            }
 | 
			
		||||
            option.setActive(option.code === this.form.baseCode);
 | 
			
		||||
        }
 | 
			
		||||
        if (baseCode === "") {
 | 
			
		||||
        if (this.form.baseCode === null) {
 | 
			
		||||
            this.#clearButton.classList.add("btn-secondary")
 | 
			
		||||
            this.#clearButton.classList.remove("btn-danger");
 | 
			
		||||
            this.#clearButton.disabled = true;
 | 
			
		||||
@@ -356,3 +335,100 @@ class BaseAccountSelector {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A base account option.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class BaseAccountOption {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The base account selector
 | 
			
		||||
     * @type {BaseAccountSelector}
 | 
			
		||||
     */
 | 
			
		||||
    #selector;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The element
 | 
			
		||||
     * @type {HTMLLIElement}
 | 
			
		||||
     */
 | 
			
		||||
    #element;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account code
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    code;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account text
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    text;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The values to query against
 | 
			
		||||
     * @type {string[]}
 | 
			
		||||
     */
 | 
			
		||||
    #queryValues;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs the account in the base account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param selector {BaseAccountSelector} the base account selector
 | 
			
		||||
     * @param element {HTMLLIElement} the element
 | 
			
		||||
     */
 | 
			
		||||
    constructor(selector, element) {
 | 
			
		||||
        this.#selector = selector;
 | 
			
		||||
        this.#element = element;
 | 
			
		||||
        this.code = element.dataset.code;
 | 
			
		||||
        this.text = element.dataset.text;
 | 
			
		||||
        this.#queryValues = JSON.parse(element.dataset.queryValues);
 | 
			
		||||
 | 
			
		||||
        this.#element.onclick = () => this.#selector.form.saveBaseAccount(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether the account matches the query.
 | 
			
		||||
     *
 | 
			
		||||
     * @param query {string} the query term
 | 
			
		||||
     * @return {boolean} true if the option matches, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    isMatched(query) {
 | 
			
		||||
        if (query === "") {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        for (const queryValue of this.#queryValues) {
 | 
			
		||||
            if (queryValue.toLowerCase().includes(query.toLowerCase())) {
 | 
			
		||||
                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");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,224 +0,0 @@
 | 
			
		||||
/* The Mia! Accounting Flask Project
 | 
			
		||||
 * account-selector.js: The JavaScript for the account selector
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*  Copyright (c) 2023 imacat.
 | 
			
		||||
 *
 | 
			
		||||
 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 *  you may not use this file except in compliance with the License.
 | 
			
		||||
 *  You may obtain a copy of the License at
 | 
			
		||||
 *
 | 
			
		||||
 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *
 | 
			
		||||
 *  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 *  See the License for the specific language governing permissions and
 | 
			
		||||
 *  limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/28
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The account selector.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class AccountSelector {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The line item editor
 | 
			
		||||
     * @type {JournalEntryLineItemEditor}
 | 
			
		||||
     */
 | 
			
		||||
    #lineItemEditor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Either "debit" or "credit"
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #debitCredit;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #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.
 | 
			
		||||
     *
 | 
			
		||||
     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
			
		||||
     * @param debitCredit {string} either "debit" or "credit"
 | 
			
		||||
     */
 | 
			
		||||
    constructor(lineItemEditor, debitCredit) {
 | 
			
		||||
        this.#lineItemEditor = lineItemEditor
 | 
			
		||||
        this.#debitCredit = debitCredit;
 | 
			
		||||
        this.#prefix = "accounting-account-selector-" + debitCredit;
 | 
			
		||||
        this.#query = document.getElementById(this.#prefix + "-query");
 | 
			
		||||
        this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
 | 
			
		||||
        this.#optionList = document.getElementById(this.#prefix + "-option-list");
 | 
			
		||||
        // noinspection JSValidateTypes
 | 
			
		||||
        this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
			
		||||
        this.#more = document.getElementById(this.#prefix + "-more");
 | 
			
		||||
        this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
 | 
			
		||||
        this.#more.onclick = () => {
 | 
			
		||||
            this.#more.classList.add("d-none");
 | 
			
		||||
            this.#filterOptions();
 | 
			
		||||
        };
 | 
			
		||||
        this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
 | 
			
		||||
        }
 | 
			
		||||
        this.#query.addEventListener("input", () => {
 | 
			
		||||
            this.#filterOptions();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filters the options.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #filterOptions() {
 | 
			
		||||
        const codesInUse = this.#getCodesUsedInForm();
 | 
			
		||||
        let shouldAnyShow = false;
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
 | 
			
		||||
            if (shouldShow) {
 | 
			
		||||
                option.classList.remove("d-none");
 | 
			
		||||
                shouldAnyShow = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.add("d-none");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
 | 
			
		||||
            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");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the account codes that are used in the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string[]} the account codes that are used in the form
 | 
			
		||||
     */
 | 
			
		||||
    #getCodesUsedInForm() {
 | 
			
		||||
        const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
 | 
			
		||||
        if (this.#lineItemEditor.accountCode !== null) {
 | 
			
		||||
            inUse.push(this.#lineItemEditor.accountCode);
 | 
			
		||||
        }
 | 
			
		||||
        return inUse
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether an option should show.
 | 
			
		||||
     *
 | 
			
		||||
     * @param option {HTMLLIElement} the option
 | 
			
		||||
     * @param more {HTMLLIElement} the more element
 | 
			
		||||
     * @param inUse {string[]} the account codes that are used in the form
 | 
			
		||||
     * @param query {HTMLInputElement} the query element, if any
 | 
			
		||||
     * @return {boolean} true if the option should show, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #shouldOptionShow(option, more, inUse, query) {
 | 
			
		||||
        const isQueryMatched = () => {
 | 
			
		||||
            if (query.value === "") {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
			
		||||
            for (const queryValue of queryValues) {
 | 
			
		||||
                if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
 | 
			
		||||
                    return true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
        const isMoreMatched = () => {
 | 
			
		||||
            if (more.classList.contains("d-none")) {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
            return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
 | 
			
		||||
        };
 | 
			
		||||
        return isMoreMatched() && isQueryMatched();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the account selector is shown.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    onOpen() {
 | 
			
		||||
        this.#query.value = "";
 | 
			
		||||
        this.#more.classList.remove("d-none");
 | 
			
		||||
        this.#filterOptions();
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.dataset.code === this.#lineItemEditor.accountCode) {
 | 
			
		||||
                option.classList.add("active");
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.remove("active");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (this.#lineItemEditor.accountCode === null) {
 | 
			
		||||
            this.#clearButton.classList.add("btn-secondary");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-danger");
 | 
			
		||||
            this.#clearButton.disabled = true;
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#clearButton.classList.add("btn-danger");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-secondary");
 | 
			
		||||
            this.#clearButton.disabled = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the account selector instances.
 | 
			
		||||
     *
 | 
			
		||||
     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
			
		||||
     * @return {{debit: AccountSelector, credit: AccountSelector}}
 | 
			
		||||
     */
 | 
			
		||||
    static getInstances(lineItemEditor) {
 | 
			
		||||
        const selectors = {}
 | 
			
		||||
        const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
 | 
			
		||||
        for (const modal of modals) {
 | 
			
		||||
            selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
 | 
			
		||||
        }
 | 
			
		||||
        return selectors;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -81,7 +81,7 @@ class CurrencyForm {
 | 
			
		||||
            this.#validateName();
 | 
			
		||||
        };
 | 
			
		||||
        this.#formElement.onsubmit = () => {
 | 
			
		||||
            this.#validateForm().then((isValid) => {
 | 
			
		||||
            this.#validate().then((isValid) => {
 | 
			
		||||
                if (isValid) {
 | 
			
		||||
                    this.#formElement.submit();
 | 
			
		||||
                }
 | 
			
		||||
@@ -95,7 +95,7 @@ class CurrencyForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {Promise<boolean>} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    async #validateForm() {
 | 
			
		||||
    async #validate() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = await this.#validateCode() && isValid;
 | 
			
		||||
        isValid = this.#validateName() && isValid;
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ class DescriptionEditor {
 | 
			
		||||
     * The line item editor
 | 
			
		||||
     * @type {JournalEntryLineItemEditor}
 | 
			
		||||
     */
 | 
			
		||||
    #lineItemEditor;
 | 
			
		||||
    lineItemEditor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The description editor form
 | 
			
		||||
@@ -41,7 +41,7 @@ class DescriptionEditor {
 | 
			
		||||
    #form;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * The prefix of the HTML ID and class names
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    prefix;
 | 
			
		||||
@@ -102,7 +102,7 @@ class DescriptionEditor {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab planes
 | 
			
		||||
     * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
 | 
			
		||||
     * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}}
 | 
			
		||||
     */
 | 
			
		||||
    tabPlanes = {};
 | 
			
		||||
 | 
			
		||||
@@ -113,7 +113,7 @@ class DescriptionEditor {
 | 
			
		||||
     * @param debitCredit {string} either "debit" or "credit"
 | 
			
		||||
     */
 | 
			
		||||
    constructor(lineItemEditor, debitCredit) {
 | 
			
		||||
        this.#lineItemEditor = lineItemEditor;
 | 
			
		||||
        this.lineItemEditor = lineItemEditor;
 | 
			
		||||
        this.debitCredit = debitCredit;
 | 
			
		||||
        this.prefix = "accounting-description-editor-" + debitCredit;
 | 
			
		||||
        this.#form = document.getElementById(this.prefix);
 | 
			
		||||
@@ -125,14 +125,14 @@ class DescriptionEditor {
 | 
			
		||||
        // noinspection JSValidateTypes
 | 
			
		||||
        this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
 | 
			
		||||
 | 
			
		||||
        for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
 | 
			
		||||
        for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) {
 | 
			
		||||
            const tab = new cls(this);
 | 
			
		||||
            this.tabPlanes[tab.tabId()] = tab;
 | 
			
		||||
        }
 | 
			
		||||
        this.currentTab = this.tabPlanes.general;
 | 
			
		||||
        this.#initializeSuggestedAccounts();
 | 
			
		||||
        this.description.onchange = () => this.#onDescriptionChange();
 | 
			
		||||
        this.#offsetButton.onclick = () => this.#lineItemEditor.originalLineItemSelector.onOpen();
 | 
			
		||||
        this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
 | 
			
		||||
        this.#form.onsubmit = () => {
 | 
			
		||||
            if (this.currentTab.validate()) {
 | 
			
		||||
                this.#submit();
 | 
			
		||||
@@ -147,7 +147,7 @@ class DescriptionEditor {
 | 
			
		||||
     */
 | 
			
		||||
    #onDescriptionChange() {
 | 
			
		||||
        this.description.value = this.description.value.trim();
 | 
			
		||||
        for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
 | 
			
		||||
        for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
 | 
			
		||||
            if (tabPlane.populate()) {
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
@@ -158,27 +158,31 @@ class DescriptionEditor {
 | 
			
		||||
    /**
 | 
			
		||||
     * Filters the suggested accounts.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tagButton {HTMLButtonElement|null} the tag button
 | 
			
		||||
     * @param tagButton {HTMLButtonElement} the tag button
 | 
			
		||||
     */
 | 
			
		||||
    filterSuggestedAccounts(tagButton) {
 | 
			
		||||
        for (const accountButton of this.#accountButtons) {
 | 
			
		||||
            accountButton.classList.add("d-none");
 | 
			
		||||
        }
 | 
			
		||||
        if (tagButton === null) {
 | 
			
		||||
            this.#selectAccount(null);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        this.clearSuggestedAccounts();
 | 
			
		||||
        const suggested = JSON.parse(tagButton.dataset.accounts);
 | 
			
		||||
        let selectedAccountButton = null;
 | 
			
		||||
        for (const accountButton of this.#accountButtons) {
 | 
			
		||||
            if (suggested.includes(accountButton.dataset.code)) {
 | 
			
		||||
                accountButton.classList.remove("d-none");
 | 
			
		||||
                if (accountButton.dataset.code === suggested[0]) {
 | 
			
		||||
                    selectedAccountButton = accountButton;
 | 
			
		||||
                    this.#selectAccount(accountButton);
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#selectAccount(selectedAccountButton);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clears the suggested accounts.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    clearSuggestedAccounts() {
 | 
			
		||||
        for (const accountButton of this.#accountButtons) {
 | 
			
		||||
            accountButton.classList.add("d-none");
 | 
			
		||||
        }
 | 
			
		||||
        this.#selectAccount(null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -215,9 +219,9 @@ class DescriptionEditor {
 | 
			
		||||
    #submit() {
 | 
			
		||||
        bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
 | 
			
		||||
        if (this.#selectedAccount !== null) {
 | 
			
		||||
            this.#lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
 | 
			
		||||
            this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#lineItemEditor.saveDescription(this.description.value);
 | 
			
		||||
            this.lineItemEditor.saveDescription(this.description.value);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -227,7 +231,7 @@ class DescriptionEditor {
 | 
			
		||||
     */
 | 
			
		||||
    onOpen() {
 | 
			
		||||
        this.#reset();
 | 
			
		||||
        this.description.value = this.#lineItemEditor.description === null? "": this.#lineItemEditor.description;
 | 
			
		||||
        this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
 | 
			
		||||
        this.#onDescriptionChange();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -274,7 +278,7 @@ class TabPlane {
 | 
			
		||||
    editor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and classes
 | 
			
		||||
     * The prefix of the HTML ID and class names
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    prefix;
 | 
			
		||||
@@ -418,7 +422,7 @@ class TagTabPlane extends TabPlane {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!isMatched) {
 | 
			
		||||
            this.editor.filterSuggestedAccounts(null);
 | 
			
		||||
            this.editor.clearSuggestedAccounts();
 | 
			
		||||
        }
 | 
			
		||||
        this.validateTag();
 | 
			
		||||
    }
 | 
			
		||||
@@ -436,14 +440,13 @@ class TagTabPlane extends TabPlane {
 | 
			
		||||
     */
 | 
			
		||||
    switchToMe() {
 | 
			
		||||
        super.switchToMe();
 | 
			
		||||
        let selectedTagButton = null;
 | 
			
		||||
        for (const tagButton of this.tagButtons) {
 | 
			
		||||
            if (tagButton.classList.contains("btn-primary")) {
 | 
			
		||||
                selectedTagButton = tagButton;
 | 
			
		||||
                break;
 | 
			
		||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.editor.filterSuggestedAccounts(selectedTagButton);
 | 
			
		||||
        this.editor.clearSuggestedAccounts();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -561,13 +564,6 @@ class GeneralTagTab extends TagTabPlane {
 | 
			
		||||
            this.tag.value = found[1];
 | 
			
		||||
            this.onTagChange();
 | 
			
		||||
        }
 | 
			
		||||
        for (const tagButton of this.tagButtons) {
 | 
			
		||||
            if (tagButton.dataset.value === this.tag.value) {
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.switchToMe();
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
@@ -732,13 +728,6 @@ class GeneralTripTab extends TagTabPlane {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#to.value = found[4];
 | 
			
		||||
        for (const tagButton of this.tagButtons) {
 | 
			
		||||
            if (tagButton.dataset.value === this.tag.value) {
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.switchToMe();
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
@@ -917,14 +906,6 @@ class BusTripTab extends TagTabPlane {
 | 
			
		||||
        this.#route.value = found[2];
 | 
			
		||||
        this.#from.value = found[3];
 | 
			
		||||
        this.#to.value = found[4];
 | 
			
		||||
        for (const tagButton of this.tagButtons) {
 | 
			
		||||
            if (tagButton.dataset.value === this.tag.value) {
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                this.editor.filterSuggestedAccounts(tagButton);
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.switchToMe();
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
@@ -985,19 +966,24 @@ class BusTripTab extends TagTabPlane {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The regular payment tab plane.
 | 
			
		||||
 * The recurring transaction tab plane.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
class RegularPaymentTab extends TabPlane {
 | 
			
		||||
class RecurringTransactionTab extends TabPlane {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The payment buttons
 | 
			
		||||
     * The month names
 | 
			
		||||
     * @type {string[]}
 | 
			
		||||
     */
 | 
			
		||||
    #monthNames;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The buttons of the recurring items
 | 
			
		||||
     * @type {HTMLButtonElement[]}
 | 
			
		||||
     */
 | 
			
		||||
    #payments;
 | 
			
		||||
    #itemButtons;
 | 
			
		||||
 | 
			
		||||
    // noinspection JSValidateTypes
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a tab plane.
 | 
			
		||||
     *
 | 
			
		||||
@@ -1006,8 +992,44 @@ class RegularPaymentTab extends TabPlane {
 | 
			
		||||
     */
 | 
			
		||||
    constructor(editor) {
 | 
			
		||||
        super(editor);
 | 
			
		||||
        this.#monthNames = [
 | 
			
		||||
            "",
 | 
			
		||||
            A_("January"), A_("February"), A_("March"), A_("April"),
 | 
			
		||||
            A_("May"), A_("June"), A_("July"), A_("August"),
 | 
			
		||||
            A_("September"), A_("October"), A_("November"), A_("December"),
 | 
			
		||||
        ];
 | 
			
		||||
        // noinspection JSValidateTypes
 | 
			
		||||
        this.#payments = Array.from(document.getElementsByClassName(this.prefix + "-payment"));
 | 
			
		||||
        this.#itemButtons = Array.from(document.getElementsByClassName(this.prefix + "-item"));
 | 
			
		||||
        for (const itemButton of this.#itemButtons) {
 | 
			
		||||
            itemButton.onclick = () => {
 | 
			
		||||
                this.reset();
 | 
			
		||||
                itemButton.classList.add("btn-primary");
 | 
			
		||||
                itemButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                this.editor.description.value = this.#getDescription(itemButton);
 | 
			
		||||
                this.editor.filterSuggestedAccounts(itemButton);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the description for a recurring item.
 | 
			
		||||
     *
 | 
			
		||||
     * @param itemButton {HTMLButtonElement} the recurring item
 | 
			
		||||
     * @return {string} the description of the recurring item
 | 
			
		||||
     */
 | 
			
		||||
    #getDescription(itemButton) {
 | 
			
		||||
        const today = new Date(this.editor.lineItemEditor.form.date);
 | 
			
		||||
        const thisMonth = today.getMonth() + 1;
 | 
			
		||||
        const lastMonth = (thisMonth + 10) % 12 + 1;
 | 
			
		||||
        const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1);
 | 
			
		||||
        const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1);
 | 
			
		||||
        return itemButton.dataset.template
 | 
			
		||||
            .replaceAll("{this_month_number}", String(thisMonth))
 | 
			
		||||
            .replaceAll("{this_month_name}", this.#monthNames[thisMonth])
 | 
			
		||||
            .replaceAll("{last_month_number}", String(lastMonth))
 | 
			
		||||
            .replaceAll("{last_month_name}", this.#monthNames[lastMonth])
 | 
			
		||||
            .replaceAll("{last_bimonthly_number}", String(lastBimonthlyFrom) + "–" + String(lastBimonthlyTo))
 | 
			
		||||
            .replaceAll("{last_bimonthly_name}", this.#monthNames[lastBimonthlyFrom] + "–" + this.#monthNames[lastBimonthlyTo]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -1017,7 +1039,7 @@ class RegularPaymentTab extends TabPlane {
 | 
			
		||||
     * @abstract
 | 
			
		||||
     */
 | 
			
		||||
    tabId() {
 | 
			
		||||
        return "regular";
 | 
			
		||||
        return "recurring";
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -1026,9 +1048,9 @@ class RegularPaymentTab extends TabPlane {
 | 
			
		||||
     * @override
 | 
			
		||||
     */
 | 
			
		||||
    reset() {
 | 
			
		||||
        for (const payment of this.#payments) {
 | 
			
		||||
            payment.classList.remove("btn-primary");
 | 
			
		||||
            payment.classList.add("btn-outline-primary");
 | 
			
		||||
        for (const itemButton of this.#itemButtons) {
 | 
			
		||||
            itemButton.classList.remove("btn-primary");
 | 
			
		||||
            itemButton.classList.add("btn-outline-primary");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -1039,9 +1061,32 @@ class RegularPaymentTab extends TabPlane {
 | 
			
		||||
     * @override
 | 
			
		||||
     */
 | 
			
		||||
    populate() {
 | 
			
		||||
        for (const itemButton of this.#itemButtons) {
 | 
			
		||||
            if (this.#getDescription(itemButton) === this.editor.description.value) {
 | 
			
		||||
                itemButton.classList.add("btn-primary");
 | 
			
		||||
                itemButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                this.switchToMe();
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Switches to the tab plane.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    switchToMe() {
 | 
			
		||||
        super.switchToMe();
 | 
			
		||||
        for (const itemButton of this.#itemButtons) {
 | 
			
		||||
            if (itemButton.classList.contains("btn-primary")) {
 | 
			
		||||
                this.editor.filterSuggestedAccounts(itemButton);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.editor.clearSuggestedAccounts();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the input in the tab plane.
 | 
			
		||||
     *
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										319
									
								
								src/accounting/static/js/journal-entry-account-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								src/accounting/static/js/journal-entry-account-selector.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,319 @@
 | 
			
		||||
/* The Mia! Accounting Flask Project
 | 
			
		||||
 * journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*  Copyright (c) 2023 imacat.
 | 
			
		||||
 *
 | 
			
		||||
 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 *  you may not use this file except in compliance with the License.
 | 
			
		||||
 *  You may obtain a copy of the License at
 | 
			
		||||
 *
 | 
			
		||||
 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *
 | 
			
		||||
 *  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 *  See the License for the specific language governing permissions and
 | 
			
		||||
 *  limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/28
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The account selector.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class JournalEntryAccountSelector {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The line item editor
 | 
			
		||||
     * @type {JournalEntryLineItemEditor}
 | 
			
		||||
     */
 | 
			
		||||
    lineItemEditor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Either "debit" or "credit"
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #debitCredit;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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 {JournalEntryAccountOption[]}
 | 
			
		||||
     */
 | 
			
		||||
    #options;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The more item to show all accounts
 | 
			
		||||
     * @type {HTMLLIElement}
 | 
			
		||||
     */
 | 
			
		||||
    #more;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether to show all accounts
 | 
			
		||||
     * @type {boolean}
 | 
			
		||||
     */
 | 
			
		||||
    #isShowMore = false;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs an account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
			
		||||
     * @param debitCredit {string} either "debit" or "credit"
 | 
			
		||||
     */
 | 
			
		||||
    constructor(lineItemEditor, debitCredit) {
 | 
			
		||||
        this.lineItemEditor = lineItemEditor
 | 
			
		||||
        this.#debitCredit = debitCredit;
 | 
			
		||||
        const prefix = "accounting-account-selector-" + debitCredit;
 | 
			
		||||
        this.#query = document.getElementById(prefix + "-query");
 | 
			
		||||
        this.#queryNoResult = document.getElementById(prefix + "-option-no-result");
 | 
			
		||||
        this.#optionList = document.getElementById(prefix + "-option-list");
 | 
			
		||||
        this.#options = Array.from(document.getElementsByClassName(prefix + "-option")).map((element) => new JournalEntryAccountOption(this, element));
 | 
			
		||||
        this.#more = document.getElementById(prefix + "-more");
 | 
			
		||||
        this.#clearButton = document.getElementById(prefix + "-btn-clear");
 | 
			
		||||
 | 
			
		||||
        this.#more.onclick = () => {
 | 
			
		||||
            this.#isShowMore = true;
 | 
			
		||||
            this.#more.classList.add("d-none");
 | 
			
		||||
            this.#filterOptions();
 | 
			
		||||
        };
 | 
			
		||||
        this.#query.oninput = () => this.#filterOptions();
 | 
			
		||||
        this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filters the options.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #filterOptions() {
 | 
			
		||||
        const codesInUse = this.#getCodesUsedInForm();
 | 
			
		||||
        let isAnyMatched = false;
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
 | 
			
		||||
                option.setShown(true);
 | 
			
		||||
                isAnyMatched = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                option.setShown(false);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!isAnyMatched) {
 | 
			
		||||
            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");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the account codes that are used in the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string[]} the account codes that are used in the form
 | 
			
		||||
     */
 | 
			
		||||
    #getCodesUsedInForm() {
 | 
			
		||||
        const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
 | 
			
		||||
        if (this.lineItemEditor.accountCode !== null) {
 | 
			
		||||
            inUse.push(this.lineItemEditor.accountCode);
 | 
			
		||||
        }
 | 
			
		||||
        return inUse
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the account selector is shown.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    onOpen() {
 | 
			
		||||
        this.#query.value = "";
 | 
			
		||||
        this.#isShowMore = false;
 | 
			
		||||
        this.#more.classList.remove("d-none");
 | 
			
		||||
        this.#filterOptions();
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            option.setActive(option.code === this.lineItemEditor.accountCode);
 | 
			
		||||
        }
 | 
			
		||||
        if (this.lineItemEditor.accountCode === null) {
 | 
			
		||||
            this.#clearButton.classList.add("btn-secondary");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-danger");
 | 
			
		||||
            this.#clearButton.disabled = true;
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#clearButton.classList.add("btn-danger");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-secondary");
 | 
			
		||||
            this.#clearButton.disabled = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the account selector instances.
 | 
			
		||||
     *
 | 
			
		||||
     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
 | 
			
		||||
     * @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
 | 
			
		||||
     */
 | 
			
		||||
    static getInstances(lineItemEditor) {
 | 
			
		||||
        const selectors = {}
 | 
			
		||||
        const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
 | 
			
		||||
        for (const modal of modals) {
 | 
			
		||||
            selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit);
 | 
			
		||||
        }
 | 
			
		||||
        return selectors;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * An account option
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class JournalEntryAccountOption {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account selector
 | 
			
		||||
     * @type {JournalEntryAccountSelector}
 | 
			
		||||
     */
 | 
			
		||||
    #selector;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The element
 | 
			
		||||
     * @type {HTMLLIElement}
 | 
			
		||||
     */
 | 
			
		||||
    #element;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account code
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    code;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account text
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    text;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether the account is in use
 | 
			
		||||
     * @type {boolean}
 | 
			
		||||
     */
 | 
			
		||||
    #isInUse;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether line items in the account need offset
 | 
			
		||||
     * @type {boolean}
 | 
			
		||||
     */
 | 
			
		||||
    isNeedOffset;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The values to query against
 | 
			
		||||
     * @type {string[]}
 | 
			
		||||
     */
 | 
			
		||||
    #queryValues;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs the account in the account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param selector {JournalEntryAccountSelector} the account selector
 | 
			
		||||
     * @param element {HTMLLIElement} the element
 | 
			
		||||
     */
 | 
			
		||||
    constructor(selector, element) {
 | 
			
		||||
        this.#selector = selector;
 | 
			
		||||
        this.#element = element;
 | 
			
		||||
        this.code = element.dataset.code;
 | 
			
		||||
        this.text = element.dataset.text;
 | 
			
		||||
        this.#isInUse = element.classList.contains("accounting-account-is-in-use");
 | 
			
		||||
        this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
 | 
			
		||||
        this.#queryValues = JSON.parse(element.dataset.queryValues);
 | 
			
		||||
 | 
			
		||||
        this.#element.onclick = () => this.#selector.lineItemEditor.saveAccount(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether the account matches the query.
 | 
			
		||||
     *
 | 
			
		||||
     * @param isShowMore {boolean} true to show all accounts, or false to show only those in use
 | 
			
		||||
     * @param codesInUse {string[]} the account codes that are used in the form
 | 
			
		||||
     * @param query {string} the query term
 | 
			
		||||
     * @return {boolean} true if the option matches, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    isMatched(isShowMore, codesInUse, query) {
 | 
			
		||||
        return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether the account matches the "in-use" condition.
 | 
			
		||||
     *
 | 
			
		||||
     * @param isShowMore {boolean} true to show all accounts, or false to show only those in use
 | 
			
		||||
     * @param codesInUse {string[]} the account codes that are used in the form
 | 
			
		||||
     * @return {boolean} true if the option matches, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #isInUseMatched(isShowMore, codesInUse) {
 | 
			
		||||
        return isShowMore || this.#isInUse || codesInUse.includes(this.code);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether the account matches the query term.
 | 
			
		||||
     *
 | 
			
		||||
     * @param query {string} the query term
 | 
			
		||||
     * @return {boolean} true if the option matches, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #isQueryMatched(query) {
 | 
			
		||||
        if (query === "") {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        for (const queryValue of this.#queryValues) {
 | 
			
		||||
            if (queryValue.toLowerCase().includes(query.toLowerCase())) {
 | 
			
		||||
                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");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -111,6 +111,7 @@ class JournalEntryForm {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.#element = document.getElementById("accounting-form");
 | 
			
		||||
        this.lineItemTemplate = this.#element.dataset.lineItemTemplate;
 | 
			
		||||
        this.lineItemEditor = new JournalEntryLineItemEditor(this);
 | 
			
		||||
        this.#date = document.getElementById("accounting-date");
 | 
			
		||||
        this.#dateError = document.getElementById("accounting-date-error");
 | 
			
		||||
        this.#currencyControl = document.getElementById("accounting-currencies");
 | 
			
		||||
@@ -121,7 +122,6 @@ class JournalEntryForm {
 | 
			
		||||
        this.#addCurrencyButton = document.getElementById("accounting-add-currency");
 | 
			
		||||
        this.#note = document.getElementById("accounting-note");
 | 
			
		||||
        this.#noteError = document.getElementById("accounting-note-error");
 | 
			
		||||
        this.lineItemEditor = new JournalEntryLineItemEditor(this);
 | 
			
		||||
 | 
			
		||||
        this.#addCurrencyButton.onclick = () => {
 | 
			
		||||
            const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index)));
 | 
			
		||||
@@ -159,7 +159,7 @@ class JournalEntryForm {
 | 
			
		||||
     */
 | 
			
		||||
    #resetDeleteCurrencyButtons() {
 | 
			
		||||
        if (this.#currencies.length === 1) {
 | 
			
		||||
            this.#currencies[0].deleteButton.classList.add("d-none");
 | 
			
		||||
            this.#currencies[0].setDeleteButtonShown(false);
 | 
			
		||||
        } else {
 | 
			
		||||
            for (const currency of this.#currencies) {
 | 
			
		||||
                let isAnyLineItemMatched = false;
 | 
			
		||||
@@ -169,11 +169,7 @@ class JournalEntryForm {
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if (isAnyLineItemMatched) {
 | 
			
		||||
                    currency.deleteButton.classList.add("d-none");
 | 
			
		||||
                } else {
 | 
			
		||||
                    currency.deleteButton.classList.remove("d-none");
 | 
			
		||||
                }
 | 
			
		||||
                currency.setDeleteButtonShown(!isAnyLineItemMatched);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@@ -184,10 +180,8 @@ class JournalEntryForm {
 | 
			
		||||
     */
 | 
			
		||||
    #initializeDragAndDropReordering() {
 | 
			
		||||
        initializeDragAndDropReordering(this.#currencyList, () => {
 | 
			
		||||
            const currencyId = Array.from(this.#currencyList.children).map((currency) => currency.id);
 | 
			
		||||
            this.#currencies.sort((a, b) => currencyId.indexOf(a.element.id) - currencyId.indexOf(b.element.id));
 | 
			
		||||
            for (let i = 0; i < this.#currencies.length; i++) {
 | 
			
		||||
                this.#currencies[i].no.value = String(i + 1);
 | 
			
		||||
            for (const currency of this.#currencies) {
 | 
			
		||||
                currency.resetNo();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
@@ -213,7 +207,7 @@ class JournalEntryForm {
 | 
			
		||||
     * @return {string[]} the account codes used in the form
 | 
			
		||||
     */
 | 
			
		||||
    getAccountCodesUsed(debitCredit) {
 | 
			
		||||
        return this.getLineItems(debitCredit).map((lineItem) => lineItem.getAccountCode())
 | 
			
		||||
        return this.getLineItems(debitCredit).map((lineItem) => lineItem.accountCode)
 | 
			
		||||
            .filter((code) => code !== null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -222,7 +216,7 @@ class JournalEntryForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string} the date
 | 
			
		||||
     */
 | 
			
		||||
    getDate() {
 | 
			
		||||
    get date() {
 | 
			
		||||
        return this.#date.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -233,7 +227,7 @@ class JournalEntryForm {
 | 
			
		||||
    updateMinDate() {
 | 
			
		||||
        let lastOriginalLineItemDate = null;
 | 
			
		||||
        for (const lineItem of this.getLineItems()) {
 | 
			
		||||
            const date = lineItem.getOriginalLineItemDate();
 | 
			
		||||
            const date = lineItem.originalLineItemDate;
 | 
			
		||||
            if (date !== null) {
 | 
			
		||||
                if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) {
 | 
			
		||||
                    lastOriginalLineItemDate = date;
 | 
			
		||||
@@ -349,7 +343,7 @@ class CurrencySubForm {
 | 
			
		||||
     * The element
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    element;
 | 
			
		||||
    #element;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The journal entry form
 | 
			
		||||
@@ -363,12 +357,6 @@ class CurrencySubForm {
 | 
			
		||||
     */
 | 
			
		||||
    index;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #prefix;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
@@ -385,7 +373,7 @@ class CurrencySubForm {
 | 
			
		||||
     * The number
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    no;
 | 
			
		||||
    #no;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The currency code
 | 
			
		||||
@@ -403,7 +391,7 @@ class CurrencySubForm {
 | 
			
		||||
     * The button to delete the currency
 | 
			
		||||
     * @type {HTMLButtonElement}
 | 
			
		||||
     */
 | 
			
		||||
    deleteButton;
 | 
			
		||||
    #deleteButton;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The debit sub-form
 | 
			
		||||
@@ -424,36 +412,58 @@ class CurrencySubForm {
 | 
			
		||||
     * @param element {HTMLDivElement} the currency sub-form element
 | 
			
		||||
     */
 | 
			
		||||
    constructor(form, element) {
 | 
			
		||||
        this.element = element;
 | 
			
		||||
        this.#element = element;
 | 
			
		||||
        this.form = form;
 | 
			
		||||
        this.index = parseInt(this.element.dataset.index);
 | 
			
		||||
        this.#prefix = "accounting-currency-" + String(this.index);
 | 
			
		||||
        this.#control = document.getElementById(this.#prefix + "-control");
 | 
			
		||||
        this.#error = document.getElementById(this.#prefix + "-error");
 | 
			
		||||
        this.no = document.getElementById(this.#prefix + "-no");
 | 
			
		||||
        this.#code = document.getElementById(this.#prefix + "-code");
 | 
			
		||||
        this.#codeSelect = document.getElementById(this.#prefix + "-code-select");
 | 
			
		||||
        this.deleteButton = document.getElementById(this.#prefix + "-delete");
 | 
			
		||||
        const debitElement = document.getElementById(this.#prefix + "-debit");
 | 
			
		||||
        this.index = parseInt(this.#element.dataset.index);
 | 
			
		||||
        const prefix = "accounting-currency-" + String(this.index);
 | 
			
		||||
        this.#control = document.getElementById(prefix + "-control");
 | 
			
		||||
        this.#error = document.getElementById(prefix + "-error");
 | 
			
		||||
        this.#no = document.getElementById(prefix + "-no");
 | 
			
		||||
        this.#code = document.getElementById(prefix + "-code");
 | 
			
		||||
        this.#codeSelect = document.getElementById(prefix + "-code-select");
 | 
			
		||||
        this.#deleteButton = document.getElementById(prefix + "-delete");
 | 
			
		||||
        const debitElement = document.getElementById(prefix + "-debit");
 | 
			
		||||
        this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit");
 | 
			
		||||
        const creditElement = document.getElementById(this.#prefix + "-credit");
 | 
			
		||||
        const creditElement = document.getElementById(prefix + "-credit");
 | 
			
		||||
        this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit");
 | 
			
		||||
        this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value;
 | 
			
		||||
        this.deleteButton.onclick = () => {
 | 
			
		||||
            this.element.parentElement.removeChild(this.element);
 | 
			
		||||
        this.#deleteButton.onclick = () => {
 | 
			
		||||
            this.#element.parentElement.removeChild(this.#element);
 | 
			
		||||
            this.form.deleteCurrency(this);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Reset the order number.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    resetNo() {
 | 
			
		||||
        const siblings = Array.from(this.#element.parentElement.children);
 | 
			
		||||
        this.#no.value = String(siblings.indexOf(this.#element) + 1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the currency code.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string} the currency code
 | 
			
		||||
     */
 | 
			
		||||
    getCurrencyCode() {
 | 
			
		||||
    get currencyCode() {
 | 
			
		||||
        return this.#code.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets whether the delete button is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param isShown {boolean} true to show, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    setDeleteButtonShown(isShown) {
 | 
			
		||||
        if (isShown) {
 | 
			
		||||
            this.#deleteButton.classList.remove("d-none");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#deleteButton.classList.add("d-none");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns all the line items in the form.
 | 
			
		||||
     *
 | 
			
		||||
@@ -479,7 +489,7 @@ class CurrencySubForm {
 | 
			
		||||
    updateCodeSelectorStatus() {
 | 
			
		||||
        let isEnabled = true;
 | 
			
		||||
        for (const lineItem of this.getLineItems()) {
 | 
			
		||||
            if (lineItem.getOriginalLineItemId() !== null) {
 | 
			
		||||
            if (lineItem.originalLineItemId !== null) {
 | 
			
		||||
                isEnabled = false;
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
@@ -511,7 +521,7 @@ class CurrencySubForm {
 | 
			
		||||
     */
 | 
			
		||||
    validateBalance() {
 | 
			
		||||
        if (this.#debit !== null && this.#credit !== null) {
 | 
			
		||||
            if (!this.#debit.getTotal().equals(this.#credit.getTotal())) {
 | 
			
		||||
            if (!this.#debit.total.equals(this.#credit.total)) {
 | 
			
		||||
                this.#control.classList.add("is-invalid");
 | 
			
		||||
                this.#error.innerText = A_("The totals of the debit and credit amounts do not match.");
 | 
			
		||||
                return false;
 | 
			
		||||
@@ -541,6 +551,12 @@ class DebitCreditSubForm {
 | 
			
		||||
     */
 | 
			
		||||
    #element;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The content
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #content;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The currencyIndex
 | 
			
		||||
     * @type {number}
 | 
			
		||||
@@ -554,7 +570,7 @@ class DebitCreditSubForm {
 | 
			
		||||
    debitCredit;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * The prefix of the HTML ID and class names
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #prefix;
 | 
			
		||||
@@ -602,24 +618,36 @@ class DebitCreditSubForm {
 | 
			
		||||
        this.#currencyIndex = currency.index;
 | 
			
		||||
        this.debitCredit = debitCredit;
 | 
			
		||||
        this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit;
 | 
			
		||||
        this.#content = document.getElementById(this.#prefix + "-content");
 | 
			
		||||
        this.#error = document.getElementById(this.#prefix + "-error");
 | 
			
		||||
        this.#lineItemList = document.getElementById(this.#prefix + "-list");
 | 
			
		||||
        // noinspection JSValidateTypes
 | 
			
		||||
        this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element));
 | 
			
		||||
        this.#total = document.getElementById(this.#prefix + "-total");
 | 
			
		||||
        this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item");
 | 
			
		||||
 | 
			
		||||
        this.#resetContent();
 | 
			
		||||
        this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this);
 | 
			
		||||
        this.#resetDeleteLineItemButtons();
 | 
			
		||||
        this.#initializeDragAndDropReordering();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the line item editor is closed.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    onLineItemEditorClosed() {
 | 
			
		||||
        if (this.lineItems.length === 0) {
 | 
			
		||||
            this.#element.classList.remove("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds a new line item sub-form
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {LineItemSubForm} the newly-added line item sub-form
 | 
			
		||||
     */
 | 
			
		||||
    addLineItem() {
 | 
			
		||||
        const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.lineItemIndex)));
 | 
			
		||||
        const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.index)));
 | 
			
		||||
        const html = this.currency.form.lineItemTemplate
 | 
			
		||||
            .replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
 | 
			
		||||
            .replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit))
 | 
			
		||||
@@ -627,6 +655,7 @@ class DebitCreditSubForm {
 | 
			
		||||
        this.#lineItemList.insertAdjacentHTML("beforeend", html);
 | 
			
		||||
        const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
 | 
			
		||||
        this.lineItems.push(lineItem);
 | 
			
		||||
        this.#resetContent();
 | 
			
		||||
        this.#resetDeleteLineItemButtons();
 | 
			
		||||
        this.#initializeDragAndDropReordering();
 | 
			
		||||
        this.validate();
 | 
			
		||||
@@ -644,6 +673,7 @@ class DebitCreditSubForm {
 | 
			
		||||
        this.updateTotal();
 | 
			
		||||
        this.currency.updateCodeSelectorStatus();
 | 
			
		||||
        this.currency.form.updateMinDate();
 | 
			
		||||
        this.#resetContent();
 | 
			
		||||
        this.#resetDeleteLineItemButtons();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -653,27 +683,48 @@ class DebitCreditSubForm {
 | 
			
		||||
     */
 | 
			
		||||
    #resetDeleteLineItemButtons() {
 | 
			
		||||
        if (this.lineItems.length === 1) {
 | 
			
		||||
            this.lineItems[0].deleteButton.classList.add("d-none");
 | 
			
		||||
            this.lineItems[0].setDeleteButtonShown(false);
 | 
			
		||||
        } else {
 | 
			
		||||
            for (const lineItem of this.lineItems) {
 | 
			
		||||
                if (lineItem.isMatched) {
 | 
			
		||||
                    lineItem.deleteButton.classList.add("d-none");
 | 
			
		||||
                } else {
 | 
			
		||||
                    lineItem.deleteButton.classList.remove("d-none");
 | 
			
		||||
                }
 | 
			
		||||
                lineItem.setDeleteButtonShown(!lineItem.isMatched);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Resets the layout of the content.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #resetContent() {
 | 
			
		||||
        if (this.lineItems.length === 0) {
 | 
			
		||||
            this.#element.classList.remove("accounting-not-empty");
 | 
			
		||||
            this.#element.classList.add("accounting-clickable");
 | 
			
		||||
            this.#element.dataset.bsToggle = "modal"
 | 
			
		||||
            this.#element.dataset.bsTarget = "#" + this.currency.form.lineItemEditor.modal.id;
 | 
			
		||||
            this.#element.onclick = () => {
 | 
			
		||||
                this.#element.classList.add("accounting-not-empty");
 | 
			
		||||
                this.currency.form.lineItemEditor.onAddNew(this);
 | 
			
		||||
            };
 | 
			
		||||
            this.#content.classList.add("d-none");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#element.classList.add("accounting-not-empty");
 | 
			
		||||
            this.#element.classList.remove("accounting-clickable");
 | 
			
		||||
            delete this.#element.dataset.bsToggle;
 | 
			
		||||
            delete this.#element.dataset.bsTarget;
 | 
			
		||||
            this.#element.onclick = null;
 | 
			
		||||
            this.#content.classList.remove("d-none");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the total amount.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {Decimal} the total amount
 | 
			
		||||
     */
 | 
			
		||||
    getTotal() {
 | 
			
		||||
    get total() {
 | 
			
		||||
        let total = new Decimal("0");
 | 
			
		||||
        for (const lineItem of this.lineItems) {
 | 
			
		||||
            const amount = lineItem.getAmount();
 | 
			
		||||
            const amount = lineItem.amount;
 | 
			
		||||
            if (amount !== null) {
 | 
			
		||||
                total = total.plus(amount);
 | 
			
		||||
            }
 | 
			
		||||
@@ -686,7 +737,7 @@ class DebitCreditSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    updateTotal() {
 | 
			
		||||
        this.#total.innerText = formatDecimal(this.getTotal());
 | 
			
		||||
        this.#total.innerText = formatDecimal(this.total);
 | 
			
		||||
        this.currency.validateBalance();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -696,10 +747,8 @@ class DebitCreditSubForm {
 | 
			
		||||
     */
 | 
			
		||||
    #initializeDragAndDropReordering() {
 | 
			
		||||
        initializeDragAndDropReordering(this.#lineItemList, () => {
 | 
			
		||||
            const lineItemId = Array.from(this.#lineItemList.children).map((lineItem) => lineItem.id);
 | 
			
		||||
            this.lineItems.sort((a, b) => lineItemId.indexOf(a.element.id) - lineItemId.indexOf(b.element.id));
 | 
			
		||||
            for (let i = 0; i < this.lineItems.length; i++) {
 | 
			
		||||
                this.lineItems[i].no.value = String(i + 1);
 | 
			
		||||
            for (const lineItem of this.lineItems) {
 | 
			
		||||
                lineItem.resetNo();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
@@ -751,7 +800,7 @@ class LineItemSubForm {
 | 
			
		||||
     * The element
 | 
			
		||||
     * @type {HTMLLIElement}
 | 
			
		||||
     */
 | 
			
		||||
    element;
 | 
			
		||||
    #element;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Either "debit" or "credit"
 | 
			
		||||
@@ -763,7 +812,7 @@ class LineItemSubForm {
 | 
			
		||||
     * The line item index
 | 
			
		||||
     * @type {number}
 | 
			
		||||
     */
 | 
			
		||||
    lineItemIndex;
 | 
			
		||||
    index;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether this is an original line item with offsets
 | 
			
		||||
@@ -771,12 +820,6 @@ class LineItemSubForm {
 | 
			
		||||
     */
 | 
			
		||||
    isMatched;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #prefix;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
@@ -793,7 +836,7 @@ class LineItemSubForm {
 | 
			
		||||
     * The number
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    no;
 | 
			
		||||
    #no;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account code
 | 
			
		||||
@@ -853,7 +896,7 @@ class LineItemSubForm {
 | 
			
		||||
     * The button to delete line item
 | 
			
		||||
     * @type {HTMLButtonElement}
 | 
			
		||||
     */
 | 
			
		||||
    deleteButton;
 | 
			
		||||
    #deleteButton;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs the line item sub-form.
 | 
			
		||||
@@ -863,38 +906,47 @@ class LineItemSubForm {
 | 
			
		||||
     */
 | 
			
		||||
    constructor(debitCredit, element) {
 | 
			
		||||
        this.debitCreditSubForm = debitCredit;
 | 
			
		||||
        this.element = element;
 | 
			
		||||
        this.#element = element;
 | 
			
		||||
        this.debitCredit = element.dataset.debitCredit;
 | 
			
		||||
        this.lineItemIndex = parseInt(element.dataset.lineItemIndex);
 | 
			
		||||
        this.index = parseInt(element.dataset.lineItemIndex);
 | 
			
		||||
        this.isMatched = element.classList.contains("accounting-matched-line-item");
 | 
			
		||||
        this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + this.lineItemIndex;
 | 
			
		||||
        this.#control = document.getElementById(this.#prefix + "-control");
 | 
			
		||||
        this.#error = document.getElementById(this.#prefix + "-error");
 | 
			
		||||
        this.no = document.getElementById(this.#prefix + "-no");
 | 
			
		||||
        this.#accountCode = document.getElementById(this.#prefix + "-account-code");
 | 
			
		||||
        this.#accountText = document.getElementById(this.#prefix + "-account-text");
 | 
			
		||||
        this.#description = document.getElementById(this.#prefix + "-description");
 | 
			
		||||
        this.#descriptionText = document.getElementById(this.#prefix + "-description-text");
 | 
			
		||||
        this.#originalLineItemId = document.getElementById(this.#prefix + "-original-line-item-id");
 | 
			
		||||
        this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item-text");
 | 
			
		||||
        this.#offsets = document.getElementById(this.#prefix + "-offsets");
 | 
			
		||||
        this.#amount = document.getElementById(this.#prefix + "-amount");
 | 
			
		||||
        this.#amountText = document.getElementById(this.#prefix + "-amount-text");
 | 
			
		||||
        this.deleteButton = document.getElementById(this.#prefix + "-delete");
 | 
			
		||||
        const prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + String(this.index);
 | 
			
		||||
        this.#control = document.getElementById(prefix + "-control");
 | 
			
		||||
        this.#error = document.getElementById(prefix + "-error");
 | 
			
		||||
        this.#no = document.getElementById(prefix + "-no");
 | 
			
		||||
        this.#accountCode = document.getElementById(prefix + "-account-code");
 | 
			
		||||
        this.#accountText = document.getElementById(prefix + "-account-text");
 | 
			
		||||
        this.#description = document.getElementById(prefix + "-description");
 | 
			
		||||
        this.#descriptionText = document.getElementById(prefix + "-description-text");
 | 
			
		||||
        this.#originalLineItemId = document.getElementById(prefix + "-original-line-item-id");
 | 
			
		||||
        this.#originalLineItemText = document.getElementById(prefix + "-original-line-item-text");
 | 
			
		||||
        this.#offsets = document.getElementById(prefix + "-offsets");
 | 
			
		||||
        this.#amount = document.getElementById(prefix + "-amount");
 | 
			
		||||
        this.#amountText = document.getElementById(prefix + "-amount-text");
 | 
			
		||||
        this.#deleteButton = document.getElementById(prefix + "-delete");
 | 
			
		||||
        this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this);
 | 
			
		||||
        this.deleteButton.onclick = () => {
 | 
			
		||||
            this.element.parentElement.removeChild(this.element);
 | 
			
		||||
        this.#deleteButton.onclick = () => {
 | 
			
		||||
            this.#element.parentElement.removeChild(this.#element);
 | 
			
		||||
            this.debitCreditSubForm.deleteLineItem(this);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Reset the order number.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    resetNo() {
 | 
			
		||||
        const siblings = Array.from(this.#element.parentElement.children);
 | 
			
		||||
        this.#no.value = String(siblings.indexOf(this.#element) + 1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether the line item needs offset.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if the line item needs offset, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    isNeedOffset() {
 | 
			
		||||
        return "isNeedOffset" in this.element.dataset;
 | 
			
		||||
    get isNeedOffset() {
 | 
			
		||||
        return "isNeedOffset" in this.#element.dataset;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -902,7 +954,7 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the ID of the original line item
 | 
			
		||||
     */
 | 
			
		||||
    getOriginalLineItemId() {
 | 
			
		||||
    get originalLineItemId() {
 | 
			
		||||
        return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -911,7 +963,7 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the date of the original line item
 | 
			
		||||
     */
 | 
			
		||||
    getOriginalLineItemDate() {
 | 
			
		||||
    get originalLineItemDate() {
 | 
			
		||||
        return this.#originalLineItemId.dataset.date === ""? null: this.#originalLineItemId.dataset.date;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -920,7 +972,7 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the text of the original line item
 | 
			
		||||
     */
 | 
			
		||||
    getOriginalLineItemText() {
 | 
			
		||||
    get originalLineItemText() {
 | 
			
		||||
        return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -929,7 +981,7 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the description
 | 
			
		||||
     */
 | 
			
		||||
    getDescription() {
 | 
			
		||||
    get description() {
 | 
			
		||||
        return this.#description.value === ""? null: this.#description.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -938,7 +990,7 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the account code
 | 
			
		||||
     */
 | 
			
		||||
    getAccountCode() {
 | 
			
		||||
    get accountCode() {
 | 
			
		||||
        return this.#accountCode.value === ""? null: this.#accountCode.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -947,7 +999,7 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the account text
 | 
			
		||||
     */
 | 
			
		||||
    getAccountText() {
 | 
			
		||||
    get accountText() {
 | 
			
		||||
        return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -956,7 +1008,7 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {Decimal|null} the amount
 | 
			
		||||
     */
 | 
			
		||||
    getAmount() {
 | 
			
		||||
    get amount() {
 | 
			
		||||
        return this.#amount.value === ""? null: new Decimal(this.#amount.value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -965,10 +1017,23 @@ class LineItemSubForm {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {Decimal|null} the minimal amount
 | 
			
		||||
     */
 | 
			
		||||
    getAmountMin() {
 | 
			
		||||
    get amountMin() {
 | 
			
		||||
        return this.#amount.dataset.min === ""? null: new Decimal(this.#amount.dataset.min);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets whether the delete button is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param isShown {boolean} true to show, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    setDeleteButtonShown(isShown) {
 | 
			
		||||
        if (isShown) {
 | 
			
		||||
            this.#deleteButton.classList.remove("d-none");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#deleteButton.classList.add("d-none");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the form.
 | 
			
		||||
     *
 | 
			
		||||
 
 | 
			
		||||
@@ -44,7 +44,7 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
     * The bootstrap modal
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #modal;
 | 
			
		||||
    modal;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Either "debit" or "credit"
 | 
			
		||||
@@ -53,7 +53,7 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
    debitCredit;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * The prefix of the HTML ID and class names
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #prefix = "accounting-line-item-editor"
 | 
			
		||||
@@ -190,12 +190,6 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
     */
 | 
			
		||||
    description = null;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The amount
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    amount = "";
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The description editors
 | 
			
		||||
     * @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
 | 
			
		||||
@@ -204,7 +198,7 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account selectors
 | 
			
		||||
     * @type {{debit: AccountSelector, credit: AccountSelector}}
 | 
			
		||||
     * @type {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
 | 
			
		||||
     */
 | 
			
		||||
    #accountSelectors;
 | 
			
		||||
 | 
			
		||||
@@ -222,7 +216,7 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
    constructor(form) {
 | 
			
		||||
        this.form = form;
 | 
			
		||||
        this.#element = document.getElementById(this.#prefix);
 | 
			
		||||
        this.#modal = document.getElementById(this.#prefix + "-modal");
 | 
			
		||||
        this.modal = document.getElementById(this.#prefix + "-modal");
 | 
			
		||||
        this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
 | 
			
		||||
        this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
 | 
			
		||||
        this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
 | 
			
		||||
@@ -237,8 +231,9 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
        this.#amountInput = document.getElementById(this.#prefix + "-amount");
 | 
			
		||||
        this.#amountError = document.getElementById(this.#prefix + "-amount-error");
 | 
			
		||||
        this.#descriptionEditors = DescriptionEditor.getInstances(this);
 | 
			
		||||
        this.#accountSelectors = AccountSelector.getInstances(this);
 | 
			
		||||
        this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
 | 
			
		||||
        this.originalLineItemSelector = new OriginalLineItemSelector(this);
 | 
			
		||||
 | 
			
		||||
        this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
 | 
			
		||||
        this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
 | 
			
		||||
        this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
 | 
			
		||||
@@ -249,12 +244,30 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
                if (this.lineItem === null) {
 | 
			
		||||
                    this.lineItem = this.#debitCreditSubForm.addLineItem();
 | 
			
		||||
                }
 | 
			
		||||
                this.amount = this.#amountInput.value;
 | 
			
		||||
                this.lineItem.save(this);
 | 
			
		||||
                bootstrap.Modal.getInstance(this.#modal).hide();
 | 
			
		||||
                bootstrap.Modal.getInstance(this.modal).hide();
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
        this.modal.addEventListener("hidden.bs.modal", () => this.#debitCreditSubForm.onLineItemEditorClosed());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the amount.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string} the amount
 | 
			
		||||
     */
 | 
			
		||||
    get amount() {
 | 
			
		||||
        return this.#amountInput.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the currency code.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string} the currency code
 | 
			
		||||
     */
 | 
			
		||||
    get currencyCode() {
 | 
			
		||||
        return this.#debitCreditSubForm.currency.currencyCode;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -308,15 +321,6 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
        this.#amountInput.max = "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the currency code.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string} the currency code
 | 
			
		||||
     */
 | 
			
		||||
    getCurrencyCode() {
 | 
			
		||||
        return this.#debitCreditSubForm.currency.getCurrencyCode();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Saves the description from the description editor.
 | 
			
		||||
     *
 | 
			
		||||
@@ -331,7 +335,7 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
        this.description = description === ""? null: description;
 | 
			
		||||
        this.#descriptionText.innerText = description;
 | 
			
		||||
        this.#validateDescription();
 | 
			
		||||
        bootstrap.Modal.getOrCreateInstance(this.#modal).show();
 | 
			
		||||
        bootstrap.Modal.getOrCreateInstance(this.modal).show();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -366,18 +370,16 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets the account.
 | 
			
		||||
     * Saves the selected account.
 | 
			
		||||
     *
 | 
			
		||||
     * @param code {string} the account code
 | 
			
		||||
     * @param text {string} the account text
 | 
			
		||||
     * @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
 | 
			
		||||
     * @param account {JournalEntryAccountOption} the selected account
 | 
			
		||||
     */
 | 
			
		||||
    saveAccount(code, text, isNeedOffset) {
 | 
			
		||||
        this.isNeedOffset = isNeedOffset;
 | 
			
		||||
    saveAccount(account) {
 | 
			
		||||
        this.isNeedOffset = account.isNeedOffset;
 | 
			
		||||
        this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        this.accountCode = code;
 | 
			
		||||
        this.accountText = text;
 | 
			
		||||
        this.#accountText.innerText = text;
 | 
			
		||||
        this.accountCode = account.code;
 | 
			
		||||
        this.accountText = account.text;
 | 
			
		||||
        this.#accountText.innerText = account.text;
 | 
			
		||||
        this.#validateAccount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -520,10 +522,10 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
        this.lineItem = lineItem;
 | 
			
		||||
        this.#debitCreditSubForm = lineItem.debitCreditSubForm;
 | 
			
		||||
        this.debitCredit = this.#debitCreditSubForm.debitCredit;
 | 
			
		||||
        this.isNeedOffset = lineItem.isNeedOffset();
 | 
			
		||||
        this.originalLineItemId = lineItem.getOriginalLineItemId();
 | 
			
		||||
        this.originalLineItemDate = lineItem.getOriginalLineItemDate();
 | 
			
		||||
        this.originalLineItemText = lineItem.getOriginalLineItemText();
 | 
			
		||||
        this.isNeedOffset = lineItem.isNeedOffset;
 | 
			
		||||
        this.originalLineItemId = lineItem.originalLineItemId;
 | 
			
		||||
        this.originalLineItemDate = lineItem.originalLineItemDate;
 | 
			
		||||
        this.originalLineItemText = lineItem.originalLineItemText;
 | 
			
		||||
        this.#originalLineItemText.innerText = this.originalLineItemText;
 | 
			
		||||
        if (this.originalLineItemId === null) {
 | 
			
		||||
            this.#originalLineItemContainer.classList.add("d-none");
 | 
			
		||||
@@ -533,25 +535,25 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
            this.#originalLineItemControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
 | 
			
		||||
        this.description = lineItem.getDescription();
 | 
			
		||||
        this.description = lineItem.description;
 | 
			
		||||
        if (this.description === null) {
 | 
			
		||||
            this.#descriptionControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#descriptionControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.#descriptionText.innerText = this.description === null? "": this.description;
 | 
			
		||||
        if (lineItem.getAccountCode() === null) {
 | 
			
		||||
        if (lineItem.accountCode === null) {
 | 
			
		||||
            this.#accountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.accountCode = lineItem.getAccountCode();
 | 
			
		||||
        this.accountText = lineItem.getAccountText();
 | 
			
		||||
        this.accountCode = lineItem.accountCode;
 | 
			
		||||
        this.accountText = lineItem.accountText;
 | 
			
		||||
        this.#accountText.innerText = this.accountText;
 | 
			
		||||
        this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
 | 
			
		||||
        this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount);
 | 
			
		||||
        const maxAmount = this.#getMaxAmount();
 | 
			
		||||
        this.#amountInput.max = maxAmount === null? "": maxAmount;
 | 
			
		||||
        this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
 | 
			
		||||
        this.#amountInput.min = lineItem.amountMin === null? "": String(lineItem.amountMin);
 | 
			
		||||
        this.#validate();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1036
									
								
								src/accounting/static/js/option-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1036
									
								
								src/accounting/static/js/option-form.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -35,7 +35,7 @@ class OriginalLineItemSelector {
 | 
			
		||||
    lineItemEditor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * The prefix of the HTML ID and class names
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #prefix = "accounting-original-line-item-selector";
 | 
			
		||||
@@ -96,9 +96,7 @@ class OriginalLineItemSelector {
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            this.#optionById[option.id] = option;
 | 
			
		||||
        }
 | 
			
		||||
        this.#query.addEventListener("input", () => {
 | 
			
		||||
            this.#filterOptions();
 | 
			
		||||
        });
 | 
			
		||||
        this.#query.oninput = () => this.#filterOptions();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -113,8 +111,8 @@ class OriginalLineItemSelector {
 | 
			
		||||
        const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
 | 
			
		||||
        let otherOffset = new Decimal(0);
 | 
			
		||||
        for (const otherLineItem of otherLineItems) {
 | 
			
		||||
            if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
 | 
			
		||||
                const amount = otherLineItem.getAmount();
 | 
			
		||||
            if (otherLineItem.originalLineItemId === originalLineItemId) {
 | 
			
		||||
                const amount = otherLineItem.amount;
 | 
			
		||||
                if (amount !== null) {
 | 
			
		||||
                    otherOffset = otherOffset.plus(amount);
 | 
			
		||||
                }
 | 
			
		||||
@@ -131,8 +129,8 @@ class OriginalLineItemSelector {
 | 
			
		||||
        const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
 | 
			
		||||
        const otherOffsets = {}
 | 
			
		||||
        for (const otherLineItem of otherLineItems) {
 | 
			
		||||
            const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
 | 
			
		||||
            const amount = otherLineItem.getAmount();
 | 
			
		||||
            const otherOriginalLineItemId = otherLineItem.originalLineItemId;
 | 
			
		||||
            const amount = otherLineItem.amount;
 | 
			
		||||
            if (otherOriginalLineItemId === null || amount === null) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
@@ -155,16 +153,16 @@ class OriginalLineItemSelector {
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #filterOptions() {
 | 
			
		||||
        let hasAnyMatched = false;
 | 
			
		||||
        let isAnyMatched = false;
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
 | 
			
		||||
                option.setShown(true);
 | 
			
		||||
                hasAnyMatched = true;
 | 
			
		||||
                isAnyMatched = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                option.setShown(false);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!hasAnyMatched) {
 | 
			
		||||
        if (!isAnyMatched) {
 | 
			
		||||
            this.#optionList.classList.add("d-none");
 | 
			
		||||
            this.#queryNoResult.classList.remove("d-none");
 | 
			
		||||
        } else {
 | 
			
		||||
@@ -178,7 +176,7 @@ class OriginalLineItemSelector {
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    onOpen() {
 | 
			
		||||
        this.#currencyCode = this.lineItemEditor.getCurrencyCode();
 | 
			
		||||
        this.#currencyCode = this.lineItemEditor.currencyCode;
 | 
			
		||||
        this.#debitCredit = this.lineItemEditor.debitCredit;
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            option.setActive(option.id === this.lineItemEditor.originalLineItemId);
 | 
			
		||||
@@ -275,7 +273,7 @@ class OriginalLineItem {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The values to query against
 | 
			
		||||
     * @type {string[][]}
 | 
			
		||||
     * @type {string[]}
 | 
			
		||||
     */
 | 
			
		||||
    #queryValues;
 | 
			
		||||
 | 
			
		||||
@@ -341,10 +339,10 @@ class OriginalLineItem {
 | 
			
		||||
     */
 | 
			
		||||
    isMatched(debitCredit, currencyCode, query = null) {
 | 
			
		||||
        return this.netBalance.greaterThan(0)
 | 
			
		||||
            && this.date <= this.#selector.lineItemEditor.form.getDate()
 | 
			
		||||
            && this.#isDebitCreditMatches(debitCredit)
 | 
			
		||||
            && this.date <= this.#selector.lineItemEditor.form.date
 | 
			
		||||
            && this.#isDebitCreditMatched(debitCredit)
 | 
			
		||||
            && this.#currencyCode === currencyCode
 | 
			
		||||
            && this.#isQueryMatches(query);
 | 
			
		||||
            && this.#isQueryMatched(query);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -353,34 +351,43 @@ class OriginalLineItem {
 | 
			
		||||
     * @param debitCredit {string} either "debit" or credit
 | 
			
		||||
     * @return {boolean} true if the option matches, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #isDebitCreditMatches(debitCredit) {
 | 
			
		||||
    #isDebitCreditMatched(debitCredit) {
 | 
			
		||||
        return (debitCredit === "debit" && this.#debitCredit === "credit")
 | 
			
		||||
            || (debitCredit === "credit" && this.#debitCredit === "debit");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether the original line item matches the query.
 | 
			
		||||
     * Returns whether the original line item matches the query term.
 | 
			
		||||
     *
 | 
			
		||||
     * @param query {string|null} the query term
 | 
			
		||||
     * @return {boolean} true if the option matches, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #isQueryMatches(query) {
 | 
			
		||||
    #isQueryMatched(query) {
 | 
			
		||||
        if (query === "") {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        for (const queryValue of this.#queryValues[0]) {
 | 
			
		||||
        if (this.#getNetBalanceForQuery().includes(query.toLowerCase())) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        for (const queryValue of this.#queryValues) {
 | 
			
		||||
            if (queryValue.toLowerCase().includes(query.toLowerCase())) {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        for (const queryValue of this.#queryValues[1]) {
 | 
			
		||||
            if (queryValue === query) {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the net balance in the format for query match.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string} the net balance in the format for query match
 | 
			
		||||
     */
 | 
			
		||||
    #getNetBalanceForQuery() {
 | 
			
		||||
        const frac = this.netBalance.modulo(1);
 | 
			
		||||
        const whole = Number(this.netBalance.minus(frac));
 | 
			
		||||
        return String(whole) + String(frac).substring(1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets whether the option is shown.
 | 
			
		||||
     *
 | 
			
		||||
 
 | 
			
		||||
@@ -33,12 +33,6 @@ document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
 */
 | 
			
		||||
class PeriodChooser {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    prefix;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The modal of the period chooser
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
@@ -56,8 +50,8 @@ class PeriodChooser {
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.prefix = "accounting-period-chooser";
 | 
			
		||||
        this.modal = document.getElementById(this.prefix + "-modal");
 | 
			
		||||
        const prefix = "accounting-period-chooser";
 | 
			
		||||
        this.modal = document.getElementById(prefix + "-modal");
 | 
			
		||||
        for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
 | 
			
		||||
            const tab = new cls(this);
 | 
			
		||||
            this.tabPlanes[tab.tabId()] = tab;
 | 
			
		||||
@@ -94,7 +88,7 @@ class TabPlane {
 | 
			
		||||
    chooser;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * The prefix of the HTML ID and class names
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    prefix;
 | 
			
		||||
 
 | 
			
		||||
@@ -17,9 +17,8 @@
 | 
			
		||||
"""The template globals.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from flask import current_app
 | 
			
		||||
 | 
			
		||||
from accounting.models import Currency
 | 
			
		||||
from accounting.utils.options import options
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def currency_options() -> str:
 | 
			
		||||
@@ -35,5 +34,4 @@ def default_currency_code() -> str:
 | 
			
		||||
 | 
			
		||||
    :return: The default currency code.
 | 
			
		||||
    """
 | 
			
		||||
    with current_app.app_context():
 | 
			
		||||
        return current_app.config.get("DEFAULT_CURRENCY", "USD")
 | 
			
		||||
    return options.default_currency_code
 | 
			
		||||
 
 | 
			
		||||
@@ -25,38 +25,45 @@ First written: 2023/1/31
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-gear"></i>
 | 
			
		||||
      {{ A_("Settings") }}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
      {{ A_("Edit") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
 | 
			
		||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
			
		||||
    {{ A_("Order") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Order") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
			
		||||
      <i class="fa-solid fa-trash"></i>
 | 
			
		||||
      {{ A_("Delete") }}
 | 
			
		||||
    </button>
 | 
			
		||||
    {% if obj.can_delete %}
 | 
			
		||||
      <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
			
		||||
        <i class="fa-solid fa-trash"></i>
 | 
			
		||||
        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    {% else %}
 | 
			
		||||
      <button class="btn btn-secondary" type="button" disabled="disabled">
 | 
			
		||||
        <i class="fa-solid fa-trash"></i>
 | 
			
		||||
        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
			
		||||
    <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
{% if accounting_can_edit() and obj.can_delete %}
 | 
			
		||||
  <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if request.args.next %}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,10 +27,10 @@ First written: 2023/2/1
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@@ -101,7 +101,7 @@ First written: 2023/2/1
 | 
			
		||||
 | 
			
		||||
        <ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
 | 
			
		||||
          {% for base in form.base_options %}
 | 
			
		||||
            <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">
 | 
			
		||||
            <li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-text="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
 | 
			
		||||
              {{ base }}
 | 
			
		||||
            </li>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ First written: 2023/1/30
 | 
			
		||||
 | 
			
		||||
<div class="mb-2 accounting-toolbar">
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
			
		||||
    <a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
			
		||||
      <i class="fa-solid fa-plus"></i>
 | 
			
		||||
      {{ A_("New") }}
 | 
			
		||||
    </a>
 | 
			
		||||
@@ -45,7 +45,7 @@ First written: 2023/1/30
 | 
			
		||||
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
			
		||||
    <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
			
		||||
      <i class="fa-solid fa-plus"></i>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -30,10 +30,10 @@ First written: 2023/2/2
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,10 +25,10 @@ First written: 2023/2/1
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@@ -38,7 +38,7 @@ First written: 2023/2/1
 | 
			
		||||
  {% if obj.accounts %}
 | 
			
		||||
    <div>
 | 
			
		||||
    {% for account in obj.accounts %}
 | 
			
		||||
      <a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
 | 
			
		||||
      <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
 | 
			
		||||
        {{ account }}
 | 
			
		||||
      </a>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -25,34 +25,41 @@ First written: 2023/2/6
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-gear"></i>
 | 
			
		||||
      {{ A_("Settings") }}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
      {{ A_("Edit") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
			
		||||
      <i class="fa-solid fa-trash"></i>
 | 
			
		||||
      {{ A_("Delete") }}
 | 
			
		||||
    </button>
 | 
			
		||||
    {% if obj.can_delete %}
 | 
			
		||||
      <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
			
		||||
        <i class="fa-solid fa-trash"></i>
 | 
			
		||||
        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    {% else %}
 | 
			
		||||
      <button class="btn btn-secondary" type="button" disabled="disabled">
 | 
			
		||||
        <i class="fa-solid fa-trash"></i>
 | 
			
		||||
        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
			
		||||
    <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
{% if accounting_can_edit() and obj.can_delete %}
 | 
			
		||||
  <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if request.args.next %}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,10 +27,10 @@ First written: 2023/2/6
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ First written: 2023/2/6
 | 
			
		||||
 | 
			
		||||
<div class="mb-2 accounting-toolbar">
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
 | 
			
		||||
    <a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
 | 
			
		||||
      <i class="fa-solid fa-plus"></i>
 | 
			
		||||
      {{ A_("New") }}
 | 
			
		||||
    </a>
 | 
			
		||||
@@ -45,7 +45,7 @@ First written: 2023/2/6
 | 
			
		||||
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
 | 
			
		||||
    <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
 | 
			
		||||
      <i class="fa-solid fa-plus"></i>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -51,6 +51,14 @@ First written: 2023/1/26
 | 
			
		||||
          {{ A_("Currencies") }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </li>
 | 
			
		||||
      {% if accounting_can_admin() %}
 | 
			
		||||
        <li>
 | 
			
		||||
          <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">
 | 
			
		||||
            <i class="fa-solid fa-gear"></i>
 | 
			
		||||
            {{ A_("Settings") }}
 | 
			
		||||
          </a>
 | 
			
		||||
        </li>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,10 +21,10 @@ First written: 2023/2/26
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/journal-entry/include/detail.html" %}
 | 
			
		||||
 | 
			
		||||
{% block to_transfer %}
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
 | 
			
		||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
			
		||||
    {{ A_("To Transfer") }}
 | 
			
		||||
{% block as_trasfer %}
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
 | 
			
		||||
    <i class="fa-solid fa-table-columns"></i>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -19,57 +19,15 @@ form-currency.html: The currency sub-form in the cash disbursement journal entry
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/25
 | 
			
		||||
#}
 | 
			
		||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
 | 
			
		||||
    <div class="d-flex justify-content-between mt-2 mb-3">
 | 
			
		||||
      <div class="form-floating accounting-currency-content">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </select>
 | 
			
		||||
        <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
{% extends "accounting/journal-entry/include/form-currency.html" %}
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
          <i class="fas fa-minus"></i>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="mb-3">
 | 
			
		||||
      <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
 | 
			
		||||
        <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label>
 | 
			
		||||
        <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
 | 
			
		||||
          {% for line_item_form in debit_forms %}
 | 
			
		||||
            {% with currency_index = currency_index,
 | 
			
		||||
                    debit_credit = "debit",
 | 
			
		||||
                    line_item_index = loop.index,
 | 
			
		||||
                    only_one_line_item_form = debit_forms|length == 1,
 | 
			
		||||
                    form = line_item_form.form %}
 | 
			
		||||
              {% include "accounting/journal-entry/include/form-line-item.html" %}
 | 
			
		||||
            {% endwith %}
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
        <div class="d-flex justify-content-between mb-2">
 | 
			
		||||
          <div>{{ A_("Total") }}</div>
 | 
			
		||||
          <div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div>
 | 
			
		||||
          <button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
			
		||||
            <i class="fas fa-plus"></i>
 | 
			
		||||
            {{ A_("New") }}
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
 | 
			
		||||
</div>
 | 
			
		||||
{% block line_items %}
 | 
			
		||||
  {% with currency_index = currency_index,
 | 
			
		||||
          debit_credit = "debit",
 | 
			
		||||
          line_item_forms = debit_forms,
 | 
			
		||||
          header = A_("Content"),
 | 
			
		||||
          debit_credit_total = debit_total,
 | 
			
		||||
          debit_credit_errors = debit_errors %}
 | 
			
		||||
    {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ First written: 2023/2/25
 | 
			
		||||
              currency_code_is_locked = currency_form.is_code_locked,
 | 
			
		||||
              debit_forms = currency_form.debit,
 | 
			
		||||
              debit_errors = currency_form.debit_errors,
 | 
			
		||||
              debit_total = currency_form.form.debit_total|accounting_format_amount %}
 | 
			
		||||
              debit_total = currency_form.debit_total|accounting_format_amount %}
 | 
			
		||||
        {% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
 | 
			
		||||
      {% endwith %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -37,7 +37,7 @@ First written: 2023/2/25
 | 
			
		||||
 | 
			
		||||
        <ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
 | 
			
		||||
          {% for account in account_options %}
 | 
			
		||||
            <li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
			
		||||
            <li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
			
		||||
              {{ account }}
 | 
			
		||||
            </li>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -55,8 +55,8 @@ First written: 2023/2/28
 | 
			
		||||
              </span>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li class="nav-item">
 | 
			
		||||
              <span id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab" class="nav-link accounting-clickable" aria-current="false">
 | 
			
		||||
                {{ A_("Regular") }}
 | 
			
		||||
              <span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false">
 | 
			
		||||
                {{ A_("Recurring") }}
 | 
			
		||||
              </span>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li class="nav-item">
 | 
			
		||||
@@ -74,7 +74,7 @@ First written: 2023/2/28
 | 
			
		||||
              <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div>
 | 
			
		||||
            <div class="accounting-description-editor-buttons">
 | 
			
		||||
              {% for tag in description_editor.general.tags %}
 | 
			
		||||
                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
 | 
			
		||||
                  {{ tag }}
 | 
			
		||||
@@ -91,7 +91,7 @@ First written: 2023/2/28
 | 
			
		||||
              <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div>
 | 
			
		||||
            <div class="accounting-description-editor-buttons">
 | 
			
		||||
              {% for tag in description_editor.travel.tags %}
 | 
			
		||||
                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
 | 
			
		||||
                  {{ tag }}
 | 
			
		||||
@@ -99,7 +99,7 @@ First written: 2023/2/28
 | 
			
		||||
              {% endfor %}
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="d-flex justify-content-between mt-2">
 | 
			
		||||
            <div class="d-flex justify-content-between">
 | 
			
		||||
              <div class="form-floating">
 | 
			
		||||
                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
 | 
			
		||||
                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
 | 
			
		||||
@@ -132,7 +132,7 @@ First written: 2023/2/28
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div>
 | 
			
		||||
            <div class="accounting-description-editor-buttons">
 | 
			
		||||
              {% for tag in description_editor.bus.tags %}
 | 
			
		||||
                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
 | 
			
		||||
                  {{ tag }}
 | 
			
		||||
@@ -140,7 +140,7 @@ First written: 2023/2/28
 | 
			
		||||
              {% endfor %}
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="d-flex justify-content-between mt-2">
 | 
			
		||||
            <div class="d-flex justify-content-between">
 | 
			
		||||
              <div class="form-floating me-2">
 | 
			
		||||
                <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
 | 
			
		||||
                <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
 | 
			
		||||
@@ -154,9 +154,15 @@ First written: 2023/2/28
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {# A regular income or payment #}
 | 
			
		||||
          <div id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab">
 | 
			
		||||
            {# TODO: To be done #}
 | 
			
		||||
          {# A recurring transaction #}
 | 
			
		||||
          <div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
 | 
			
		||||
            <div class="accounting-description-editor-buttons">
 | 
			
		||||
              {% for recurring in description_editor.recurring %}
 | 
			
		||||
                <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
 | 
			
		||||
                  {{ recurring.name }}
 | 
			
		||||
                </button>
 | 
			
		||||
              {% endfor %}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {# The annotation #}
 | 
			
		||||
 
 | 
			
		||||
@@ -25,32 +25,32 @@ First written: 2023/2/26
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-gear"></i>
 | 
			
		||||
      {{ A_("Settings") }}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
      {{ A_("Edit") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
 | 
			
		||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
			
		||||
    {{ A_("Order") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Order") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    {% block to_transfer %}{% endblock %}
 | 
			
		||||
    {% block as_trasfer %}{% endblock %}
 | 
			
		||||
    {% if obj.can_delete %}
 | 
			
		||||
      <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
			
		||||
        <i class="fa-solid fa-trash"></i>
 | 
			
		||||
        {{ A_("Delete") }}
 | 
			
		||||
        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    {% else %}
 | 
			
		||||
      <button class="btn btn-secondary" type="button" disabled="disabled">
 | 
			
		||||
        <i class="fa-solid fa-trash"></i>
 | 
			
		||||
        {{ A_("Delete") }}
 | 
			
		||||
        <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
@@ -58,7 +58,7 @@ First written: 2023/2/26
 | 
			
		||||
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
 | 
			
		||||
    <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,47 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
form-currency.html: The currency sub-form in the journal entry form
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/21
 | 
			
		||||
#}
 | 
			
		||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
 | 
			
		||||
    <div class="d-flex justify-content-between mt-2 mb-3">
 | 
			
		||||
      <div class="form-floating accounting-currency-content">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </select>
 | 
			
		||||
        <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
          <i class="fas fa-minus"></i>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    {% block line_items %}{% endblock %}
 | 
			
		||||
  </div>
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,51 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
form-debit-credit.html: The debit or credit line items in the journal entry form
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/21
 | 
			
		||||
#}
 | 
			
		||||
<div class="mb-2">
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field {% if line_item_forms %} accounting-not-empty {% else %} accounting-clickable {% endif %} {% if debit_errors %} is-invalid {% endif %}">
 | 
			
		||||
    <label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label>
 | 
			
		||||
    <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-content" class="mt-2 {% if not line_item_forms %} d-none {% endif %}">
 | 
			
		||||
      <ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
 | 
			
		||||
        {% for line_item_form in line_item_forms %}
 | 
			
		||||
          {% with currency_index = currency_index,
 | 
			
		||||
                  line_item_index = loop.index,
 | 
			
		||||
                  only_one_line_item_form = line_item_forms|length == 1,
 | 
			
		||||
                  form = line_item_form.form %}
 | 
			
		||||
            {% include "accounting/journal-entry/include/form-line-item.html" %}
 | 
			
		||||
          {% endwith %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </ul>
 | 
			
		||||
 | 
			
		||||
      <div class="d-flex justify-content-between mt-2 mb-2">
 | 
			
		||||
        <div>{{ A_("Total") }}</div>
 | 
			
		||||
        <div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
			
		||||
          <i class="fas fa-plus"></i>
 | 
			
		||||
          {{ A_("New") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -25,17 +25,17 @@ First written: 2023/2/26
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/journal-entry-account-selector.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,56 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
order-journal-entry.html: The journal entry in the journal entry order page
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/21
 | 
			
		||||
#}
 | 
			
		||||
<a class="small w-100 accounting-journal-entry-order-item" href="{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_append_next }}">
 | 
			
		||||
  <div>
 | 
			
		||||
    {% if journal_entry.is_cash_disbursement %}
 | 
			
		||||
      {{ A_("Cash Disbursement") }}
 | 
			
		||||
    {% elif journal_entry.is_cash_receipt %}
 | 
			
		||||
      {{ A_("Cash Receipt") }}
 | 
			
		||||
    {% else %}
 | 
			
		||||
      {{ A_("Transfer") }}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  </div>
 | 
			
		||||
  {% for currency in journal_entry.currencies %}
 | 
			
		||||
    <div class="d-flex justify-content-between accounting-journal-entry-order-item-currency">
 | 
			
		||||
      <div>
 | 
			
		||||
        {% if not journal_entry.is_cash_receipt %}
 | 
			
		||||
          {% for line_item in currency.debit %}
 | 
			
		||||
            <div>{{ line_item.description|accounting_default }}</div>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if not journal_entry.is_cash_disbursement %}
 | 
			
		||||
          {% for line_item in currency.credit %}
 | 
			
		||||
            <div class="accounting-mobile-journal-credit">{{ line_item.description|accounting_default }}</div>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div>
 | 
			
		||||
        <span class="badge bg-info rounded-pill">
 | 
			
		||||
          {% if currency.code != accounting_default_currency_code() %}
 | 
			
		||||
            {{ currency.code }}
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          {{ currency.debit_total|accounting_format_amount }}
 | 
			
		||||
        </span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
</a>
 | 
			
		||||
@@ -26,14 +26,14 @@ First written: 2023/2/26
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date) }}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date|accounting_format_date) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@@ -47,9 +47,9 @@ First written: 2023/2/26
 | 
			
		||||
      {% for item in list %}
 | 
			
		||||
        <li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
 | 
			
		||||
          <input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
 | 
			
		||||
          <div>
 | 
			
		||||
            {{ item }}
 | 
			
		||||
          </div>
 | 
			
		||||
          {% with journal_entry = item %}
 | 
			
		||||
            {% include "accounting/journal-entry/include/order-journal-entry.html" %}
 | 
			
		||||
          {% endwith %}
 | 
			
		||||
          <i class="fa-solid fa-bars"></i>
 | 
			
		||||
        </li>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
@@ -72,7 +72,9 @@ First written: 2023/2/26
 | 
			
		||||
  <ul class="list-group mb-3">
 | 
			
		||||
    {% for item in list %}
 | 
			
		||||
      <li class="list-group-item">
 | 
			
		||||
        {{ item }}
 | 
			
		||||
        {% with journal_entry = item %}
 | 
			
		||||
          {% include "accounting/journal-entry/include/order-journal-entry.html" %}
 | 
			
		||||
        {% endwith %}
 | 
			
		||||
      </li>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
  </ul>
 | 
			
		||||
 
 | 
			
		||||
@@ -21,10 +21,10 @@ First written: 2023/2/26
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/journal-entry/include/detail.html" %}
 | 
			
		||||
 | 
			
		||||
{% block to_transfer %}
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
 | 
			
		||||
{% block as_trasfer %}
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
 | 
			
		||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
			
		||||
    {{ A_("To Transfer") }}
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -19,57 +19,15 @@ form-currency.html: The currency sub-form in the cash receipt journal entry form
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/25
 | 
			
		||||
#}
 | 
			
		||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
 | 
			
		||||
    <div class="d-flex justify-content-between mt-2 mb-3">
 | 
			
		||||
      <div class="form-floating accounting-currency-content">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </select>
 | 
			
		||||
        <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
{% extends "accounting/journal-entry/include/form-currency.html" %}
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
          <i class="fas fa-minus"></i>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="mb-3">
 | 
			
		||||
      <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
 | 
			
		||||
        <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label>
 | 
			
		||||
        <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
 | 
			
		||||
          {% for line_item_form in credit_forms %}
 | 
			
		||||
            {% with currency_index = currency_index,
 | 
			
		||||
                    debit_credit = "credit",
 | 
			
		||||
                    line_item_index = loop.index,
 | 
			
		||||
                    only_one_line_item_form = credit_forms|length == 1,
 | 
			
		||||
                    form = line_item_form.form %}
 | 
			
		||||
              {% include "accounting/journal-entry/include/form-line-item.html" %}
 | 
			
		||||
            {% endwith %}
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
        <div class="d-flex justify-content-between mb-2">
 | 
			
		||||
          <div>{{ A_("Total") }}</div>
 | 
			
		||||
          <div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div>
 | 
			
		||||
          <button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
			
		||||
            <i class="fas fa-plus"></i>
 | 
			
		||||
            {{ A_("New") }}
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
 | 
			
		||||
</div>
 | 
			
		||||
{% block line_items %}
 | 
			
		||||
  {% with currency_index = currency_index,
 | 
			
		||||
          debit_credit = "credit",
 | 
			
		||||
          line_item_forms = credit_forms,
 | 
			
		||||
          header = A_("Content"),
 | 
			
		||||
          debit_credit_total = credit_total,
 | 
			
		||||
          debit_credit_errors = credit_errors %}
 | 
			
		||||
    {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ First written: 2023/2/25
 | 
			
		||||
              currency_code_is_locked = currency_form.is_code_locked,
 | 
			
		||||
              credit_forms = currency_form.credit,
 | 
			
		||||
              credit_errors = currency_form.credit_errors,
 | 
			
		||||
              credit_total = currency_form.form.credit_total|accounting_format_amount %}
 | 
			
		||||
              credit_total = currency_form.credit_total|accounting_format_amount %}
 | 
			
		||||
        {% include "accounting/journal-entry/receipt/include/form-currency.html" %}
 | 
			
		||||
      {% endwith %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,91 +19,30 @@ form-currency.html: The currency sub-form in the transfer journal entry form
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/25
 | 
			
		||||
#}
 | 
			
		||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
 | 
			
		||||
    <div class="d-flex justify-content-between mt-2 mb-3">
 | 
			
		||||
      <div class="form-floating accounting-currency-content">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </select>
 | 
			
		||||
        <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
{% extends "accounting/journal-entry/include/form-currency.html" %}
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
          <i class="fas fa-minus"></i>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="row">
 | 
			
		||||
      {# The debit line items #}
 | 
			
		||||
      <div class="col-sm-6 mb-3">
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
 | 
			
		||||
          <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label>
 | 
			
		||||
          <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
 | 
			
		||||
            {% for line_item_form in debit_forms %}
 | 
			
		||||
              {% with currency_index = currency_index,
 | 
			
		||||
                      debit_credit = "debit",
 | 
			
		||||
                      line_item_index = loop.index,
 | 
			
		||||
                      only_one_line_item_form = debit_forms|length == 1,
 | 
			
		||||
                      form = line_item_form.form %}
 | 
			
		||||
                {% include "accounting/journal-entry/include/form-line-item.html" %}
 | 
			
		||||
              {% endwith %}
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
          </ul>
 | 
			
		||||
 | 
			
		||||
          <div class="d-flex justify-content-between mb-2">
 | 
			
		||||
            <div>{{ A_("Total") }}</div>
 | 
			
		||||
            <div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div>
 | 
			
		||||
            <button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
			
		||||
              <i class="fas fa-plus"></i>
 | 
			
		||||
              {{ A_("New") }}
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {# The credit line items #}
 | 
			
		||||
      <div class="col-sm-6 mb-3">
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
 | 
			
		||||
          <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label>
 | 
			
		||||
          <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
 | 
			
		||||
            {% for line_item_form in credit_forms %}
 | 
			
		||||
              {% with currency_index = currency_index,
 | 
			
		||||
                      debit_credit = "credit",
 | 
			
		||||
                      line_item_index = loop.index,
 | 
			
		||||
                      only_one_line_item_form = credit_forms|length == 1,
 | 
			
		||||
                      form = line_item_form.form %}
 | 
			
		||||
                {% include "accounting/journal-entry/include/form-line-item.html" %}
 | 
			
		||||
              {% endwith %}
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
          </ul>
 | 
			
		||||
 | 
			
		||||
          <div class="d-flex justify-content-between mb-2">
 | 
			
		||||
            <div>{{ A_("Total") }}</div>
 | 
			
		||||
            <div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div>
 | 
			
		||||
            <button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
 | 
			
		||||
              <i class="fas fa-plus"></i>
 | 
			
		||||
              {{ A_("New") }}
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
{% block line_items %}
 | 
			
		||||
<div class="row">
 | 
			
		||||
  <div class="col-sm-6">
 | 
			
		||||
    {% with currency_index = currency_index,
 | 
			
		||||
            debit_credit = "debit",
 | 
			
		||||
            line_item_forms = debit_forms,
 | 
			
		||||
            header = A_("Debit"),
 | 
			
		||||
            debit_credit_total = debit_total,
 | 
			
		||||
            debit_credit_errors = debit_errors %}
 | 
			
		||||
      {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
			
		||||
    {% endwith %}
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="col-sm-6">
 | 
			
		||||
    {% with currency_index = currency_index,
 | 
			
		||||
            debit_credit = "credit",
 | 
			
		||||
            line_item_forms = credit_forms,
 | 
			
		||||
            header = A_("Credit"),
 | 
			
		||||
            debit_credit_total = credit_total,
 | 
			
		||||
            debit_credit_errors = credit_errors %}
 | 
			
		||||
      {% include "accounting/journal-entry/include/form-debit-credit.html" %}
 | 
			
		||||
    {% endwith %}
 | 
			
		||||
  </div>
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -32,10 +32,10 @@ First written: 2023/2/25
 | 
			
		||||
              currency_code_is_locked = currency_form.is_code_locked,
 | 
			
		||||
              debit_forms = currency_form.debit,
 | 
			
		||||
              debit_errors = currency_form.debit_errors,
 | 
			
		||||
              debit_total = currency_form.form.debit_total|accounting_format_amount,
 | 
			
		||||
              debit_total = currency_form.debit_total|accounting_format_amount,
 | 
			
		||||
              credit_forms = currency_form.credit,
 | 
			
		||||
              credit_errors = currency_form.credit_errors,
 | 
			
		||||
              credit_total = currency_form.form.credit_total|accounting_format_amount %}
 | 
			
		||||
              credit_total = currency_form.credit_total|accounting_format_amount %}
 | 
			
		||||
        {% include "accounting/journal-entry/transfer/include/form-currency.html" %}
 | 
			
		||||
      {% endwith %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										63
									
								
								src/accounting/templates/accounting/option/detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/accounting/templates/accounting/option/detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
detail.html: The option 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/22
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.option.edit")|accounting_inherit_next }}">
 | 
			
		||||
    <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
    {{ A_("Edit") }}
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="d-md-none accounting-material-fab">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.option.edit")|accounting_inherit_next }}">
 | 
			
		||||
    <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="form-floating mb-3">
 | 
			
		||||
  <input id="accounting-default-currency" class="form-control" value="{{ obj.default_currency_text }}" readonly="readonly">
 | 
			
		||||
  <label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="form-floating mb-3">
 | 
			
		||||
  <input id="accounting-default-ie-account" class="form-control" value="{{ obj.default_ie_account_code_text }}" readonly="readonly">
 | 
			
		||||
  <label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% with expense_income = "expense",
 | 
			
		||||
        label = A_("Recurring Expense"),
 | 
			
		||||
        recurring_items = obj.recurring.expenses %}
 | 
			
		||||
  {% include "accounting/option/include/detail-recurring-expense-income.html" %}
 | 
			
		||||
{% endwith %}
 | 
			
		||||
 | 
			
		||||
{% with expense_income = "income",
 | 
			
		||||
        label = A_("Recurring Income"),
 | 
			
		||||
        recurring_items = obj.recurring.incomes %}
 | 
			
		||||
  {% include "accounting/option/include/detail-recurring-expense-income.html" %}
 | 
			
		||||
{% endwith %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										109
									
								
								src/accounting/templates/accounting/option/form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/accounting/templates/accounting/option/form.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,109 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
form.html: The option form
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/22
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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/option-form.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.option.detail")|accounting_inherit_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Back") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<form id="accounting-form" action="{{ url_for("accounting.option.update") }}" method="post" data-recurring-item-template="{{ form.recurring.item_template }}">
 | 
			
		||||
  {{ form.csrf_token }}
 | 
			
		||||
  {% if request.args.next %}
 | 
			
		||||
    <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <select id="accounting-default-currency" class="form-select {% if form.default_currency_code.errors %} is-invalid {% endif %}" name="default_currency_code">
 | 
			
		||||
      {% for currency in accounting_currency_options() %}
 | 
			
		||||
        <option value="{{ currency.code }}" {% if currency.code == form.default_currency_code.data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </select>
 | 
			
		||||
    <label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label>
 | 
			
		||||
    <div id="accounting-default-currency-error" class="invalid-feedback">{% if form.default_currency_code.errors %}{{ form.default_currency_code.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <select id="accounting-default-ie-account" class="form-select {% if form.default_ie_account_code.errors %} is-invalid {% endif %}" name="default_ie_account_code">
 | 
			
		||||
      {% for account in form.current_accounts %}
 | 
			
		||||
        <option value="{{ account.code }}" {% if account.code == form.default_ie_account_code.data %} selected="selected" {% endif %}>{{ account }}</option>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </select>
 | 
			
		||||
    <label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
 | 
			
		||||
    <div id="accounting-default-ie-account-error" class="invalid-feedback">{% if form.default_ie_account_code.errors %}{{ form.default_ie_account_code.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  {% with expense_income = "expense",
 | 
			
		||||
          label = A_("Recurring Expense"),
 | 
			
		||||
          recurring_items = form.recurring.expenses %}
 | 
			
		||||
    {% include "accounting/option/include/form-recurring-expense-income.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
 | 
			
		||||
  {% with expense_income = "income",
 | 
			
		||||
          label = A_("Recurring Income"),
 | 
			
		||||
          recurring_items = form.recurring.incomes %}
 | 
			
		||||
    {% include "accounting/option/include/form-recurring-expense-income.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
 | 
			
		||||
  <div class="d-none d-md-block">
 | 
			
		||||
    <button class="btn btn-primary" type="submit">
 | 
			
		||||
      <i class="fa-solid fa-floppy-disk"></i>
 | 
			
		||||
      {{ A_("Save") }}
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <button class="btn btn-primary" type="submit">
 | 
			
		||||
      <i class="fa-solid fa-floppy-disk"></i>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
 | 
			
		||||
{% with expense_income = "expense",
 | 
			
		||||
        title = A_("Recurring Expense") %}
 | 
			
		||||
  {% include "accounting/option/include/recurring-item-editor-modal.html" %}
 | 
			
		||||
{% endwith %}
 | 
			
		||||
{% with expense_income = "income",
 | 
			
		||||
        title = A_("Recurring Income") %}
 | 
			
		||||
  {% include "accounting/option/include/recurring-item-editor-modal.html" %}
 | 
			
		||||
{% endwith %}
 | 
			
		||||
 | 
			
		||||
{% with expense_income = "expense",
 | 
			
		||||
        accounts = form.recurring.expense_accounts %}
 | 
			
		||||
  {% include "accounting/option/include/recurring-account-selector-modal.html" %}
 | 
			
		||||
{% endwith %}
 | 
			
		||||
{% with expense_income = "income",
 | 
			
		||||
        accounts = form.recurring.income_accounts %}
 | 
			
		||||
  {% include "accounting/option/include/recurring-account-selector-modal.html" %}
 | 
			
		||||
{% endwith %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,31 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
detail-recurring-expense-income.html: The recurring expense or income in the option 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/22
 | 
			
		||||
#}
 | 
			
		||||
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% endif %}">
 | 
			
		||||
  <label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
 | 
			
		||||
  {% if recurring_items %}
 | 
			
		||||
    <ul class="list-group mb-2 mt-2">
 | 
			
		||||
      {% for item in recurring_items %}
 | 
			
		||||
        {% include "accounting/option/include/detail-recurring-item.html" %}
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </ul>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,28 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
detail-recurring-item.html: The recurring item in the option 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/22
 | 
			
		||||
#}
 | 
			
		||||
{# <ul> For SonarQube not to complain about incorrect HTML #}
 | 
			
		||||
<li class="list-group-item list-group-item-action">
 | 
			
		||||
  <div class="small">{{ item.account_text }}</div>
 | 
			
		||||
  <div>{{ item.name }}</div>
 | 
			
		||||
  <div class="small">{{ item.description_template }}</div>
 | 
			
		||||
</li>
 | 
			
		||||
{# </ul> For SonarQube not to complain about incorrect HTML #}
 | 
			
		||||
@@ -0,0 +1,41 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
form-recurring-expense-income.html: The recurring expense or income sub-form in the option form
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/22
 | 
			
		||||
#}
 | 
			
		||||
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% else %} accounting-clickable {% endif %}">
 | 
			
		||||
  <label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
 | 
			
		||||
  <div id="accounting-recurring-{{ expense_income }}-content" class="{% if not recurring_items %} d-none {% endif %}">
 | 
			
		||||
    <ul id="accounting-recurring-{{ expense_income }}-list" class="list-group mb-2 mt-2">
 | 
			
		||||
      {% for recurring_item in recurring_items %}
 | 
			
		||||
        {% with form = recurring_item.form,
 | 
			
		||||
                item_index = loop.index %}
 | 
			
		||||
          {% include "accounting/option/include/form-recurring-item.html" %}
 | 
			
		||||
        {% endwith %}
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </ul>
 | 
			
		||||
 | 
			
		||||
    <div>
 | 
			
		||||
      <button id="accounting-recurring-{{ expense_income }}-add" class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
 | 
			
		||||
        <i class="fas fa-plus"></i>
 | 
			
		||||
        {{ A_("New") }}
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,45 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
form-recurring-item.html: The recurring item sub-form in the option form
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/22
 | 
			
		||||
#}
 | 
			
		||||
{# <ul> For SonarQube not to complain about incorrect HTML #}
 | 
			
		||||
<li id="accounting-recurring-{{ expense_income }}-{{ item_index }}" class="list-group-item list-group-item-action accounting-recurring-{{ expense_income }}-item" data-item-index="{{ item_index }}">
 | 
			
		||||
  <input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-no" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-no" value="{{ item_index }}">
 | 
			
		||||
  <input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-name" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-name" value="{{ form.name.data|accounting_default }}">
 | 
			
		||||
  <input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-account-code" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text|accounting_default }}">
 | 
			
		||||
  <input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-description-template" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-description_template" value="{{ form.description_template.data|accounting_default }}">
 | 
			
		||||
  <div class="d-flex justify-content-between">
 | 
			
		||||
    <div class="w-100">
 | 
			
		||||
      <div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-control" class="form-control accounting-clickable {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
 | 
			
		||||
        <div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-account-text" class="small">{{ form.account_text|accounting_default }}</div>
 | 
			
		||||
        <div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-name-text">{{ form.name.data|accounting_default }}</div>
 | 
			
		||||
        <div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-description-template-text" class="small">{{ form.description_template.data|accounting_default }}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="ms-2">
 | 
			
		||||
      <button id="accounting-recurring-{{ expense_income }}-{{ item_index }}-delete" class="btn btn-danger rounded-circle" type="button">
 | 
			
		||||
        <i class="fas fa-minus"></i>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</li>
 | 
			
		||||
{# </ul> For SonarQube not to complain about incorrect HTML #}
 | 
			
		||||
@@ -0,0 +1,53 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
recurring-account-selector-modal.html: The modal of the account selector for the recurring item editor
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/22
 | 
			
		||||
#}
 | 
			
		||||
<div id="accounting-recurring-accounting-selector-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-accounting-selector-{{ expense_income }}-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-recurring-accounting-selector-{{ expense_income }}-modal-label">{{ A_("Select Account") }}</h1>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        <div class="input-group mb-2">
 | 
			
		||||
          <input id="accounting-recurring-accounting-selector-{{ expense_income }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
 | 
			
		||||
          <label class="input-group-text" for="accounting-recurring-accounting-selector-{{ expense_income }}-query">
 | 
			
		||||
            <i class="fa-solid fa-magnifying-glass"></i>
 | 
			
		||||
            {{ A_("Search") }}
 | 
			
		||||
          </label>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <ul id="accounting-recurring-accounting-selector-{{ expense_income }}-option-list" class="list-group accounting-selector-list">
 | 
			
		||||
          {% for account in accounts %}
 | 
			
		||||
            <li id="accounting-recurring-accounting-selector-{{ expense_income }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-recurring-accounting-selector-{{ expense_income }}-option" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
 | 
			
		||||
              {{ account }}
 | 
			
		||||
            </li>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        <p id="accounting-recurring-accounting-selector-{{ expense_income }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
        <button id="accounting-recurring-accounting-selector-{{ expense_income }}-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Clear") }}</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,71 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
recurring-item-editor-modal.html: The modal of the recurring item editor
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/22
 | 
			
		||||
#}
 | 
			
		||||
<form id="accounting-recurring-item-editor-{{ expense_income }}">
 | 
			
		||||
  <div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal-dialog">
 | 
			
		||||
      <div class="modal-content">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h1 class="modal-title fs-5" id="accounting-recurring-item-editor-{{ expense_income }}-modal-label">{{ title }}</h1>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="form-floating mb-3">
 | 
			
		||||
            <input id="accounting-recurring-item-editor-{{ expense_income }}-name" class="form-control" type="text" value="" placeholder=" " required="required">
 | 
			
		||||
            <label for="accounting-recurring-item-editor-{{ expense_income }}-name">{{ A_("Name") }}</label>
 | 
			
		||||
            <div id="accounting-recurring-item-editor-{{ expense_income }}-name-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
            <div id="accounting-recurring-item-editor-{{ expense_income }}-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-recurring-accounting-selector-{{ expense_income }}-modal">
 | 
			
		||||
              <label class="form-label" for="accounting-recurring-item-editor-{{ expense_income }}-account">{{ A_("Account") }}</label>
 | 
			
		||||
              <div id="accounting-recurring-item-editor-{{ expense_income }}-account"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="accounting-recurring-item-editor-{{ expense_income }}-account-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="form-floating mb-3">
 | 
			
		||||
            <input id="accounting-recurring-item-editor-{{ expense_income }}-description-template" class="form-control" type="text" value="" placeholder=" " required="required">
 | 
			
		||||
            <label for="accounting-recurring-item-editor-{{ expense_income }}-description-template">{{ A_("Description Template") }}</label>
 | 
			
		||||
            <div id="accounting-recurring-item-editor-{{ expense_income }}-description-template-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="mb-3 border-top accounting-recurring-description-template-illustration">
 | 
			
		||||
            <p>{{ A_("Available template variables:") }}</p>
 | 
			
		||||
            <ul>
 | 
			
		||||
              <li><code>{this_month_number}</code>: {{ A_("This month, as a number.") }}</li>
 | 
			
		||||
              <li><code>{this_month_name}</code>: {{ A_("This month, in its name.") }}</li>
 | 
			
		||||
              <li><code>{last_month_number}</code>: {{ A_("Last month, as a number.") }}</li>
 | 
			
		||||
              <li><code>{last_month_name}</code>: {{ A_("Last month, in its name.") }}</li>
 | 
			
		||||
              <li><code>{last_bimonthly_number}</code>: {{ A_("The previous bimonthly period, as numbers.") }}</li>
 | 
			
		||||
              <li><code>{last_bimonthly_name}</code>: {{ A_("The previous bimonthly period, as their names.") }}</li>
 | 
			
		||||
            </ul>
 | 
			
		||||
            <p>{{ A_("Example:") }} <code>{{ A_("Water bill for {last_bimonthly_name}") }}</code></p>
 | 
			
		||||
          </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>
 | 
			
		||||
@@ -22,13 +22,13 @@ First written: 2023/2/25
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
 | 
			
		||||
    <div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
 | 
			
		||||
      <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
 | 
			
		||||
      <a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
 | 
			
		||||
        {{ A_("Cash Disbursement") }}
 | 
			
		||||
      </a>
 | 
			
		||||
      <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
 | 
			
		||||
      <a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
 | 
			
		||||
        {{ A_("Cash Receipt") }}
 | 
			
		||||
      </a>
 | 
			
		||||
      <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
 | 
			
		||||
      <a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
 | 
			
		||||
        {{ A_("Transfer") }}
 | 
			
		||||
      </a>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -45,11 +45,11 @@ First written: 2023/3/8
 | 
			
		||||
  </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
<div class="btn-group" role="group">
 | 
			
		||||
  <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
 | 
			
		||||
  <button id="accounting-choose-report" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
 | 
			
		||||
    <i class="fa-solid fa-book"></i>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Report") }}</span>
 | 
			
		||||
  </button>
 | 
			
		||||
  <ul class="dropdown-menu" aria-label="{{ A_("Report") }}">
 | 
			
		||||
  <ul class="dropdown-menu" aria-labelledby="accounting-choose-report">
 | 
			
		||||
    {% for report in report.report_chooser %}
 | 
			
		||||
      <li>
 | 
			
		||||
        <a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">
 | 
			
		||||
@@ -68,11 +68,11 @@ First written: 2023/3/8
 | 
			
		||||
</div>
 | 
			
		||||
{% if use_currency_chooser %}
 | 
			
		||||
  <div class="btn-group" role="group">
 | 
			
		||||
    <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
 | 
			
		||||
    <button id="accounting-choose-currency" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
 | 
			
		||||
      <i class="fa-solid fa-money-bill-wave"></i>
 | 
			
		||||
      <span class="d-none d-md-inline">{{ report.currency.name|title }}</span>
 | 
			
		||||
      <span class="d-none d-md-inline">{{ A_("Currency") }}</span>
 | 
			
		||||
    </button>
 | 
			
		||||
    <ul class="dropdown-menu" aria-label="{{ A_("Currency") }}">
 | 
			
		||||
    <ul class="dropdown-menu" aria-labelledby="accounting-choose-currency">
 | 
			
		||||
      {% for currency in report.currency_options %}
 | 
			
		||||
        <li>
 | 
			
		||||
          <a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
 | 
			
		||||
@@ -85,11 +85,11 @@ First written: 2023/3/8
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if use_account_chooser %}
 | 
			
		||||
  <div class="btn-group" role="group">
 | 
			
		||||
    <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
 | 
			
		||||
    <button id="accounting-choose-account" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
 | 
			
		||||
      <i class="fa-solid fa-clipboard"></i>
 | 
			
		||||
      <span class="d-none d-md-inline">{{ report.account.title|title }}</span>
 | 
			
		||||
      <span class="d-none d-md-inline">{{ A_("Account") }}</span>
 | 
			
		||||
    </button>
 | 
			
		||||
    <ul class="dropdown-menu" aria-label="{{ A_("Account") }}">
 | 
			
		||||
    <ul class="dropdown-menu" aria-labelledby="accounting-choose-account">
 | 
			
		||||
      {% for account in report.account_options %}
 | 
			
		||||
        <li>
 | 
			
		||||
          <a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
 | 
			
		||||
@@ -103,7 +103,7 @@ First written: 2023/3/8
 | 
			
		||||
{% if use_period_chooser %}
 | 
			
		||||
  <button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
 | 
			
		||||
    <i class="fa-solid fa-calendar-day"></i>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ report.period.desc|title }}</span>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Period") }}</span>
 | 
			
		||||
  </button>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if report.has_data %}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
			
		||||
"POT-Creation-Date: 2023-03-20 23:51+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-03-20 23:51+0800\n"
 | 
			
		||||
"POT-Creation-Date: 2023-03-23 00:45+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-03-23 00:46+0800\n"
 | 
			
		||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
"Language: zh_Hant\n"
 | 
			
		||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
@@ -19,22 +19,38 @@ msgstr ""
 | 
			
		||||
"Content-Transfer-Encoding: 8bit\n"
 | 
			
		||||
"Generated-By: Babel 2.12.1\n"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:538
 | 
			
		||||
#: src/accounting/forms.py:33
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:980
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
 | 
			
		||||
#: src/accounting/static/js/option-form.js:530
 | 
			
		||||
#: src/accounting/static/js/option-form.js:796
 | 
			
		||||
msgid "Please select the account."
 | 
			
		||||
msgstr "請選擇科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/forms.py:44
 | 
			
		||||
msgid "The currency does not exist."
 | 
			
		||||
msgstr "沒有這個貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/forms.py:55 src/accounting/option/forms.py:42
 | 
			
		||||
msgid "The account does not exist."
 | 
			
		||||
msgstr "沒有這個科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:563
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Cash Disbursement Journal Entry#%(id)s"
 | 
			
		||||
msgstr "現金支出傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:541
 | 
			
		||||
#: src/accounting/models.py:566
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Cash Receipt Journal Entry#%(id)s"
 | 
			
		||||
msgstr "現金收入傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:542
 | 
			
		||||
#: src/accounting/models.py:567
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Transfer Journal Entry#%(id)s"
 | 
			
		||||
msgstr "轉帳傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:679
 | 
			
		||||
#: src/accounting/models.py:700
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "%(date)s %(description)s %(amount)s"
 | 
			
		||||
msgstr "%(date)s %(description)s %(amount)s"
 | 
			
		||||
@@ -86,7 +102,7 @@ msgstr "請填上標題。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/queries.py:50
 | 
			
		||||
#: src/accounting/report/reports/search.py:101
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:90
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:97
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:62
 | 
			
		||||
msgid "Needs Offset"
 | 
			
		||||
msgstr "需抵銷"
 | 
			
		||||
@@ -103,17 +119,21 @@ msgstr "科目未異動。"
 | 
			
		||||
msgid "The account is updated successfully."
 | 
			
		||||
msgstr "科目存好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/views.py:163
 | 
			
		||||
#: src/accounting/account/views.py:161
 | 
			
		||||
msgid "The account cannot be deleted."
 | 
			
		||||
msgstr "科目不可刪除。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/views.py:166
 | 
			
		||||
msgid "The account is deleted successfully."
 | 
			
		||||
msgstr "科目刪掉了"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/views.py:190
 | 
			
		||||
#: src/accounting/journal_entry/views.py:213
 | 
			
		||||
#: src/accounting/account/views.py:193
 | 
			
		||||
#: src/accounting/journal_entry/views.py:216
 | 
			
		||||
msgid "The order was not modified."
 | 
			
		||||
msgstr "順序未異動。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/views.py:193
 | 
			
		||||
#: src/accounting/journal_entry/views.py:216
 | 
			
		||||
#: src/accounting/account/views.py:196
 | 
			
		||||
#: src/accounting/journal_entry/views.py:219
 | 
			
		||||
msgid "The order is updated successfully."
 | 
			
		||||
msgstr "順序存好了。"
 | 
			
		||||
 | 
			
		||||
@@ -137,8 +157,11 @@ msgstr "代碼限為三個大寫英文字母。"
 | 
			
		||||
msgid "This code is not available."
 | 
			
		||||
msgstr "不能用這個代碼。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/forms.py:62
 | 
			
		||||
#: src/accounting/currency/forms.py:62 src/accounting/option/forms.py:124
 | 
			
		||||
#: src/accounting/option/forms.py:148
 | 
			
		||||
#: src/accounting/static/js/currency-form.js:153
 | 
			
		||||
#: src/accounting/static/js/option-form.js:525
 | 
			
		||||
#: src/accounting/static/js/option-form.js:780
 | 
			
		||||
msgid "Please fill in the name."
 | 
			
		||||
msgstr "請填上名稱。"
 | 
			
		||||
 | 
			
		||||
@@ -154,7 +177,11 @@ msgstr "貨幣未異動。"
 | 
			
		||||
msgid "The currency is updated successfully."
 | 
			
		||||
msgstr "貨幣存好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/views.py:165
 | 
			
		||||
#: src/accounting/currency/views.py:164
 | 
			
		||||
msgid "The currency cannot be deleted."
 | 
			
		||||
msgstr "貨幣不可刪除。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/views.py:168
 | 
			
		||||
msgid "The currency is deleted successfully."
 | 
			
		||||
msgstr "貨幣刪掉了"
 | 
			
		||||
 | 
			
		||||
@@ -170,32 +197,32 @@ msgstr "傳票未異動。"
 | 
			
		||||
msgid "The journal entry is updated successfully."
 | 
			
		||||
msgstr "傳票存好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/views.py:181
 | 
			
		||||
#: src/accounting/journal_entry/views.py:179
 | 
			
		||||
msgid "The journal entry cannot be deleted."
 | 
			
		||||
msgstr "傳票不可刪除。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/views.py:184
 | 
			
		||||
msgid "The journal entry is deleted successfully."
 | 
			
		||||
msgstr "傳票刪掉了"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:38
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:40
 | 
			
		||||
msgid "Please select the currency."
 | 
			
		||||
msgstr "請選擇貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:49
 | 
			
		||||
msgid "The currency does not exist."
 | 
			
		||||
msgstr "沒有這個貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:72
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:63
 | 
			
		||||
msgid "The currency must be the same as the original line item."
 | 
			
		||||
msgstr "貨幣需和原始分錄相同。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:99
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:90
 | 
			
		||||
msgid "The currency must not be changed when there is offset."
 | 
			
		||||
msgstr "抵銷過不可變更貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:108
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:99
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:729
 | 
			
		||||
msgid "Please add some line items."
 | 
			
		||||
msgstr "請加上分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:121
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:112
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:516
 | 
			
		||||
msgid "The totals of the debit and credit amounts do not match."
 | 
			
		||||
msgstr "借方貸方合計不符。 "
 | 
			
		||||
@@ -224,62 +251,44 @@ msgstr "請加上貨幣。"
 | 
			
		||||
msgid "Line items with offset cannot be deleted."
 | 
			
		||||
msgstr "無法刪除抵銷過的分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:41
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:980
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
 | 
			
		||||
msgid "Please select the account."
 | 
			
		||||
msgstr "請選擇科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:52
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:49
 | 
			
		||||
msgid "The original line item does not exist."
 | 
			
		||||
msgstr "沒有這筆原始分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:73
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:70
 | 
			
		||||
msgid "The original line item is on the same debit or credit."
 | 
			
		||||
msgstr "原始分錄在借貸同一邊。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:88
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:85
 | 
			
		||||
msgid "The original line item does not need offset."
 | 
			
		||||
msgstr "這筆原始分錄不需抵銷。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:104
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:101
 | 
			
		||||
msgid "The original line item cannot be an offset item."
 | 
			
		||||
msgstr "原始分錄不可以是抵銷分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:115
 | 
			
		||||
msgid "The account does not exist."
 | 
			
		||||
msgstr "沒有這個科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:129
 | 
			
		||||
msgid "This account is not for debit line items."
 | 
			
		||||
msgstr "科目不是借方科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:143
 | 
			
		||||
msgid "This account is not for credit line items."
 | 
			
		||||
msgstr "科目不是貸方科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:161
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:119
 | 
			
		||||
msgid "The account must be the same as the original line item."
 | 
			
		||||
msgstr "科目需和原始分錄相同。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:179
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:137
 | 
			
		||||
msgid "The account must not be changed when there is offset."
 | 
			
		||||
msgstr "抵銷過不可變更科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:195
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:153
 | 
			
		||||
msgid "A payable line item cannot start from debit."
 | 
			
		||||
msgstr "不可由借方新建應付款。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:211
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:169
 | 
			
		||||
msgid "A receivable line item cannot start from credit."
 | 
			
		||||
msgstr "不可由貸方新建應收款。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:222
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:180
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:455
 | 
			
		||||
msgid "Please fill in a positive amount."
 | 
			
		||||
msgstr "金額請填正數。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:264
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:222
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:461
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid ""
 | 
			
		||||
@@ -287,12 +296,64 @@ msgid ""
 | 
			
		||||
"line item."
 | 
			
		||||
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:285
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:243
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:469
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "The amount must not be less than the offset total %(total)s."
 | 
			
		||||
msgstr "金額不可低於抵銷總額 %(total)s 。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:416
 | 
			
		||||
msgid "This account is not for debit line items."
 | 
			
		||||
msgstr "科目不是借方科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:468
 | 
			
		||||
msgid "This account is not for credit line items."
 | 
			
		||||
msgstr "科目不是貸方科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:53
 | 
			
		||||
msgid "This is not a current account."
 | 
			
		||||
msgstr "這不是流動科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:66
 | 
			
		||||
msgid "You cannot select a payable account as expense."
 | 
			
		||||
msgstr "支出不能選應付科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:79
 | 
			
		||||
msgid "You cannot select a receivable account as income."
 | 
			
		||||
msgstr "收入不能選應收科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:131
 | 
			
		||||
msgid "This account is not for expense."
 | 
			
		||||
msgstr "科目不是支出科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:137 src/accounting/option/forms.py:161
 | 
			
		||||
#: src/accounting/static/js/option-form.js:535
 | 
			
		||||
#: src/accounting/static/js/option-form.js:813
 | 
			
		||||
msgid "Please fill in the description template."
 | 
			
		||||
msgstr "請填上摘要範本。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:155
 | 
			
		||||
msgid "This account is not for income."
 | 
			
		||||
msgstr "科目不是收入科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:238
 | 
			
		||||
#: src/accounting/static/js/option-form.js:136
 | 
			
		||||
msgid "Please select the default currency."
 | 
			
		||||
msgstr "請選擇預設貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/forms.py:244
 | 
			
		||||
#: src/accounting/static/js/option-form.js:152
 | 
			
		||||
msgid "Please select the default account for the income and expenses log."
 | 
			
		||||
msgstr "請選擇收支帳的預設科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/views.py:79
 | 
			
		||||
msgid "The settings were not modified."
 | 
			
		||||
msgstr "設定未異動。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/option/views.py:82
 | 
			
		||||
msgid "The settings are saved successfully."
 | 
			
		||||
msgstr "設定存好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/period/description.py:33
 | 
			
		||||
msgid "for all time"
 | 
			
		||||
msgstr "全部"
 | 
			
		||||
@@ -351,20 +412,17 @@ msgstr "全部"
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:426
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:438
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:440
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:192
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:429
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:189
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:423
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:299
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:171
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:380
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:228
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:60
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:37
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/detail.html:43
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:60
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:39
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:55
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:62
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:93
 | 
			
		||||
#: src/accounting/templates/accounting/report/balance-sheet.html:59
 | 
			
		||||
#: src/accounting/templates/accounting/report/balance-sheet.html:71
 | 
			
		||||
#: src/accounting/templates/accounting/report/balance-sheet.html:81
 | 
			
		||||
@@ -383,7 +441,7 @@ msgstr "合計"
 | 
			
		||||
msgid "Brought forward"
 | 
			
		||||
msgstr "前期轉入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:413
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:407
 | 
			
		||||
#: src/accounting/report/reports/journal.py:155
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:366
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
 | 
			
		||||
@@ -395,11 +453,12 @@ msgstr "前期轉入"
 | 
			
		||||
msgid "Date"
 | 
			
		||||
msgstr "日期"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:413
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:407
 | 
			
		||||
#: src/accounting/report/reports/journal.py:156
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:224
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:92
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:56
 | 
			
		||||
#: src/accounting/templates/accounting/report/journal.html:55
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:52
 | 
			
		||||
@@ -407,7 +466,7 @@ msgstr "日期"
 | 
			
		||||
msgid "Account"
 | 
			
		||||
msgstr "科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:414
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:408
 | 
			
		||||
#: src/accounting/report/reports/journal.py:156
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:366
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
 | 
			
		||||
@@ -419,27 +478,27 @@ msgstr "科目"
 | 
			
		||||
msgid "Description"
 | 
			
		||||
msgstr "摘要"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:414
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:408
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:58
 | 
			
		||||
msgid "Income"
 | 
			
		||||
msgstr "收入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:415
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:409
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:59
 | 
			
		||||
msgid "Expense"
 | 
			
		||||
msgstr "支出"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:415
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:409
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:368
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:60
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:60
 | 
			
		||||
msgid "Balance"
 | 
			
		||||
msgstr "餘額"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:416
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:410
 | 
			
		||||
#: src/accounting/report/reports/journal.py:158
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:368
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
 | 
			
		||||
msgid "Note"
 | 
			
		||||
msgstr "備註"
 | 
			
		||||
@@ -475,10 +534,8 @@ msgid "Amount"
 | 
			
		||||
msgstr "金額"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/journal.py:155
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:75
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form-currency.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:73
 | 
			
		||||
#: src/accounting/templates/accounting/report/journal.html:54
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:51
 | 
			
		||||
msgid "Currency"
 | 
			
		||||
@@ -488,7 +545,7 @@ msgstr "貨幣"
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:367
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:224
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:48
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:30
 | 
			
		||||
#: src/accounting/templates/accounting/report/journal.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:54
 | 
			
		||||
@@ -500,7 +557,7 @@ msgstr "借方"
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:367
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:225
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:79
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:41
 | 
			
		||||
#: src/accounting/templates/accounting/report/journal.html:58
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:58
 | 
			
		||||
#: src/accounting/templates/accounting/report/search.html:55
 | 
			
		||||
@@ -508,10 +565,6 @@ msgstr "借方"
 | 
			
		||||
msgid "Credit"
 | 
			
		||||
msgstr "貸方"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/ie_account.py:64
 | 
			
		||||
msgid "current assets and liabilities"
 | 
			
		||||
msgstr "流動資產與負債"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/report_chooser.py:81
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:98
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:40
 | 
			
		||||
@@ -519,6 +572,7 @@ msgstr "流動資產與負債"
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:40
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:34
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:34
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:34
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/search-modal.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/search-modal.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/search-modal.html:38
 | 
			
		||||
@@ -555,25 +609,73 @@ msgstr "資產負債表"
 | 
			
		||||
msgid "Please fill in the title."
 | 
			
		||||
msgstr "請填上標題。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:767
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:953
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:756
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:934
 | 
			
		||||
msgid "Please fill in the tag."
 | 
			
		||||
msgstr "請填上標籤。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:777
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:973
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:766
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:954
 | 
			
		||||
msgid "Please fill in the origin."
 | 
			
		||||
msgstr "請填上起點。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:787
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:983
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:776
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:964
 | 
			
		||||
msgid "Please fill in the destination."
 | 
			
		||||
msgstr "請填上終點。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:963
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:944
 | 
			
		||||
msgid "Please fill in the route."
 | 
			
		||||
msgstr "請填上路線名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:998
 | 
			
		||||
msgid "January"
 | 
			
		||||
msgstr "一月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:998
 | 
			
		||||
msgid "February"
 | 
			
		||||
msgstr "二月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:998
 | 
			
		||||
msgid "March"
 | 
			
		||||
msgstr "三月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:998
 | 
			
		||||
msgid "April"
 | 
			
		||||
msgstr "四月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:999
 | 
			
		||||
msgid "May"
 | 
			
		||||
msgstr "五月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:999
 | 
			
		||||
msgid "June"
 | 
			
		||||
msgstr "六月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:999
 | 
			
		||||
msgid "July"
 | 
			
		||||
msgstr "七月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:999
 | 
			
		||||
msgid "August"
 | 
			
		||||
msgstr "八月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:1000
 | 
			
		||||
msgid "September"
 | 
			
		||||
msgstr "九月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:1000
 | 
			
		||||
msgid "October"
 | 
			
		||||
msgstr "十月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:1000
 | 
			
		||||
msgid "November"
 | 
			
		||||
msgstr "十一月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/description-editor.js:1000
 | 
			
		||||
msgid "December"
 | 
			
		||||
msgstr "十二月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:985
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:449
 | 
			
		||||
msgid "Please fill in the amount."
 | 
			
		||||
@@ -623,74 +725,82 @@ msgstr "新增科目"
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:31
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:38
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/order.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:36
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "回上頁"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:36
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr "設定"
 | 
			
		||||
#: src/accounting/templates/accounting/option/detail.html:31
 | 
			
		||||
msgid "Edit"
 | 
			
		||||
msgstr "編輯"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:41
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:41
 | 
			
		||||
msgid "Order"
 | 
			
		||||
msgstr "次序"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:46
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:42
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:47
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:52
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:43
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:48
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:48
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:53
 | 
			
		||||
msgid "Delete"
 | 
			
		||||
msgstr "刪除"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:69
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:76
 | 
			
		||||
msgid "Confirm Delete Account"
 | 
			
		||||
msgstr "刪除科目確認"
 | 
			
		||||
msgstr "確認刪除科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:70
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:77
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:91
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:66
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:73
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:30
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/search-modal.html:28
 | 
			
		||||
msgid "Close"
 | 
			
		||||
msgstr "關閉"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:73
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:80
 | 
			
		||||
msgid "Do you really want to delete this account?"
 | 
			
		||||
msgstr "你確定要刪掉這個科目嗎?"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:76
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:83
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:112
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:72
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:79
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:49
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:187
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:193
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:84
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:70
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/search-modal.html:37
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "取消"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:77
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:73
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:84
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:80
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:85
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:141
 | 
			
		||||
msgid "Confirm"
 | 
			
		||||
msgstr "確定"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:94
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:85
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:101
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:92
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:114
 | 
			
		||||
msgid "Created"
 | 
			
		||||
msgstr "建檔"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:95
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:86
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:102
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:93
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:115
 | 
			
		||||
msgid "Updated"
 | 
			
		||||
msgstr "更新"
 | 
			
		||||
@@ -714,11 +824,9 @@ msgstr "科目管理"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:32
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:32
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:67
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:44
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:64
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:67
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:69
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:100
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/form-recurring-expense-income.html:37
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:26
 | 
			
		||||
msgid "New"
 | 
			
		||||
msgstr "新增"
 | 
			
		||||
@@ -730,7 +838,8 @@ msgstr "新增"
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:65
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:46
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:51
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/order.html:80
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/order.html:82
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:45
 | 
			
		||||
#: src/accounting/templates/accounting/report/balance-sheet.html:110
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:113
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-statement.html:96
 | 
			
		||||
@@ -749,10 +858,12 @@ msgstr "%(base)s下的科目"
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:75
 | 
			
		||||
#: src/accounting/templates/accounting/account/order.html:62
 | 
			
		||||
#: src/accounting/templates/accounting/currency/include/form.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:188
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:194
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:80
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/order.html:61
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:80
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
 | 
			
		||||
msgid "Save"
 | 
			
		||||
msgstr "儲存"
 | 
			
		||||
 | 
			
		||||
@@ -779,6 +890,7 @@ msgstr "選擇基本科目"
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:114
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:116
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:50
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:49
 | 
			
		||||
msgid "Clear"
 | 
			
		||||
msgstr "清除"
 | 
			
		||||
 | 
			
		||||
@@ -790,11 +902,11 @@ msgstr "基本科目管理"
 | 
			
		||||
msgid "Add a New Currency"
 | 
			
		||||
msgstr "新增貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:65
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:72
 | 
			
		||||
msgid "Confirm Delete Currency"
 | 
			
		||||
msgstr "刪除貨幣確認"
 | 
			
		||||
msgstr "確認刪除貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:69
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:76
 | 
			
		||||
msgid "Do you really want to delete this currency?"
 | 
			
		||||
msgstr "你確定要刪掉這個貨幣嗎?"
 | 
			
		||||
 | 
			
		||||
@@ -812,6 +924,7 @@ msgid "Code"
 | 
			
		||||
msgstr "代碼"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/include/form.html:50
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:33
 | 
			
		||||
msgid "Name"
 | 
			
		||||
msgstr "名稱"
 | 
			
		||||
 | 
			
		||||
@@ -835,6 +948,12 @@ msgstr "基本科目"
 | 
			
		||||
msgid "Currencies"
 | 
			
		||||
msgstr "貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:58
 | 
			
		||||
#: src/accounting/templates/accounting/option/detail.html:24
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:29
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr "設定"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/include/pagination.html:23
 | 
			
		||||
msgid "Page navigation"
 | 
			
		||||
msgstr "分頁瀏覽"
 | 
			
		||||
@@ -850,14 +969,14 @@ msgstr "新增現金支出傳票"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/detail.html:27
 | 
			
		||||
msgid "To Transfer"
 | 
			
		||||
msgid "As Transfer"
 | 
			
		||||
msgstr "改轉帳"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:37
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:46
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:56
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/detail.html:37
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:46
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:28
 | 
			
		||||
msgid "Content"
 | 
			
		||||
msgstr "內容"
 | 
			
		||||
 | 
			
		||||
@@ -869,6 +988,7 @@ msgid "Editing %(journal_entry)s"
 | 
			
		||||
msgstr "編輯%(journal_entry)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:26
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:26
 | 
			
		||||
msgid "Select Account"
 | 
			
		||||
msgstr "選擇科目"
 | 
			
		||||
 | 
			
		||||
@@ -893,8 +1013,8 @@ msgid "Bus"
 | 
			
		||||
msgstr "公車"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:59
 | 
			
		||||
msgid "Regular"
 | 
			
		||||
msgstr "帳單"
 | 
			
		||||
msgid "Recurring"
 | 
			
		||||
msgstr "常用"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:64
 | 
			
		||||
msgid "Annotation"
 | 
			
		||||
@@ -922,7 +1042,7 @@ msgstr "至"
 | 
			
		||||
msgid "Route"
 | 
			
		||||
msgstr "路線"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:166
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172
 | 
			
		||||
msgid "The Number of Items"
 | 
			
		||||
msgstr "數量"
 | 
			
		||||
 | 
			
		||||
@@ -948,7 +1068,7 @@ msgstr "未抵銷"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:77
 | 
			
		||||
msgid "Confirm Delete Journal Entry"
 | 
			
		||||
msgstr "刪除傳票確認"
 | 
			
		||||
msgstr "確認刪除傳票"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:81
 | 
			
		||||
msgid "Do you really want to delete this journal entry?"
 | 
			
		||||
@@ -962,6 +1082,24 @@ msgstr "分錄內容"
 | 
			
		||||
msgid "Original Line Item"
 | 
			
		||||
msgstr "原始分錄"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:25
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:26
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:31
 | 
			
		||||
msgid "Cash Disbursement"
 | 
			
		||||
msgstr "現金支出"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:36
 | 
			
		||||
msgid "Cash Receipt"
 | 
			
		||||
msgstr "現金收入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:32
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:41
 | 
			
		||||
msgid "Transfer"
 | 
			
		||||
msgstr "轉帳"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:26
 | 
			
		||||
msgid "Select Original Line Item"
 | 
			
		||||
msgstr "選擇原始分錄"
 | 
			
		||||
@@ -974,6 +1112,68 @@ msgstr "新增現金收入傳票"
 | 
			
		||||
msgid "Add a New Transfer Journal Entry"
 | 
			
		||||
msgstr "新增轉帳傳票"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/detail.html:43
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:51
 | 
			
		||||
msgid "Default Currency"
 | 
			
		||||
msgstr "預設貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/detail.html:48
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:61
 | 
			
		||||
msgid "Default Account for the Income and Expenses Log"
 | 
			
		||||
msgstr "收支帳預設科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/detail.html:52
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:66
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:92
 | 
			
		||||
msgid "Recurring Expense"
 | 
			
		||||
msgstr "常用支出"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/detail.html:58
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:72
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:96
 | 
			
		||||
msgid "Recurring Income"
 | 
			
		||||
msgstr "常用收入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:47
 | 
			
		||||
msgid "Description Template"
 | 
			
		||||
msgstr "摘要範本"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:52
 | 
			
		||||
msgid "Available template variables:"
 | 
			
		||||
msgstr "範本變數說明:"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:54
 | 
			
		||||
msgid "This month, as a number."
 | 
			
		||||
msgstr "這個月的數字。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
 | 
			
		||||
msgid "This month, in its name."
 | 
			
		||||
msgstr "這個月的名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
 | 
			
		||||
msgid "Last month, as a number."
 | 
			
		||||
msgstr "上個月的數字。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
 | 
			
		||||
msgid "Last month, in its name."
 | 
			
		||||
msgstr "上個月的名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
 | 
			
		||||
msgid "The previous bimonthly period, as numbers."
 | 
			
		||||
msgstr "前個雙月期的數字。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
 | 
			
		||||
msgid "The previous bimonthly period, as their names."
 | 
			
		||||
msgstr "前個雙月期的名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
 | 
			
		||||
msgid "Example:"
 | 
			
		||||
msgstr "範例:"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
 | 
			
		||||
msgid "Water bill for {last_bimonthly_name}"
 | 
			
		||||
msgstr "水費{last_bimonthly_number}月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/balance-sheet.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/balance-sheet.html:49
 | 
			
		||||
#, python-format
 | 
			
		||||
@@ -1007,21 +1207,6 @@ msgstr "%(period)s%(currency)s%(account)s分類帳"
 | 
			
		||||
msgid "Trial Balance of %(currency)s %(period)s"
 | 
			
		||||
msgstr "%(period)s%(currency)s試算表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:26
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:31
 | 
			
		||||
msgid "Cash Disbursement"
 | 
			
		||||
msgstr "現金支出"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:36
 | 
			
		||||
msgid "Cash Receipt"
 | 
			
		||||
msgstr "現金收入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:32
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:41
 | 
			
		||||
msgid "Transfer"
 | 
			
		||||
msgstr "轉帳"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:26
 | 
			
		||||
msgid "Period Chooser"
 | 
			
		||||
msgstr "選擇日期範圍"
 | 
			
		||||
@@ -1042,15 +1227,23 @@ msgstr "日"
 | 
			
		||||
msgid "Custom"
 | 
			
		||||
msgstr "自訂"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:52
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:50
 | 
			
		||||
msgid "Report"
 | 
			
		||||
msgstr "報表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:106
 | 
			
		||||
msgid "Period"
 | 
			
		||||
msgstr "期間"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:112
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:117
 | 
			
		||||
msgid "Download"
 | 
			
		||||
msgstr "下載"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/current_account.py:65
 | 
			
		||||
msgid "current assets and liabilities"
 | 
			
		||||
msgstr "流動資產與負債"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/pagination.py:206
 | 
			
		||||
msgctxt "Pagination|"
 | 
			
		||||
msgid "Previous"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
@@ -14,28 +14,29 @@
 | 
			
		||||
#  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 pseudo account for the income and expenses log.
 | 
			
		||||
"""The current assets and liabilities account.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
 | 
			
		||||
from flask import current_app
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeExpensesAccount:
 | 
			
		||||
    """The pseudo account for the income and expenses log."""
 | 
			
		||||
class CurrentAccount:
 | 
			
		||||
    """A current assets and liabilities account."""
 | 
			
		||||
    CURRENT_AL_CODE: str = "0000-000"
 | 
			
		||||
    """The account code for the current assets and liabilities."""
 | 
			
		||||
    """The account code for all current assets and liabilities."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account | None = None):
 | 
			
		||||
        """Constructs the pseudo account for the income and expenses log.
 | 
			
		||||
        """Constructs the current assets and liabilities account.
 | 
			
		||||
 | 
			
		||||
        :param account: The actual account.
 | 
			
		||||
        """
 | 
			
		||||
        self.account: Account | None = account
 | 
			
		||||
        """The actual account."""
 | 
			
		||||
        self.id: int = -1 if account is None else account.id
 | 
			
		||||
        """The ID."""
 | 
			
		||||
        self.code: str = "" if account is None else account.code
 | 
			
		||||
@@ -54,9 +55,9 @@ class IncomeExpensesAccount:
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def current_assets_and_liabilities(cls) -> t.Self:
 | 
			
		||||
        """Returns the pseudo account for current assets and liabilities.
 | 
			
		||||
        """Returns the pseudo account for all current assets and liabilities.
 | 
			
		||||
 | 
			
		||||
        :return: The pseudo account for current assets and liabilities.
 | 
			
		||||
        :return: The pseudo account for all current assets and liabilities.
 | 
			
		||||
        """
 | 
			
		||||
        account: cls = cls()
 | 
			
		||||
        account.id = 0
 | 
			
		||||
@@ -65,22 +66,28 @@ class IncomeExpensesAccount:
 | 
			
		||||
        account.str = account.title
 | 
			
		||||
        return account
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def accounts(cls) -> list[t.Self]:
 | 
			
		||||
        """Returns the current assets and liabilities accounts.
 | 
			
		||||
 | 
			
		||||
def default_ie_account_code() -> str:
 | 
			
		||||
    """Returns the default account code for the income and expenses log.
 | 
			
		||||
        :return: The current assets and liabilities accounts.
 | 
			
		||||
        """
 | 
			
		||||
        accounts: list[cls] = [cls.current_assets_and_liabilities()]
 | 
			
		||||
        accounts.extend([CurrentAccount(x)
 | 
			
		||||
                         for x in db.session.query(Account)
 | 
			
		||||
                        .filter(cls.sql_condition())
 | 
			
		||||
                        .order_by(Account.base_code, Account.no)])
 | 
			
		||||
        return accounts
 | 
			
		||||
 | 
			
		||||
    :return: The default account code for the income and expenses log.
 | 
			
		||||
    """
 | 
			
		||||
    with current_app.app_context():
 | 
			
		||||
        return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def sql_condition(cls) -> sa.BinaryExpression:
 | 
			
		||||
        """Returns the SQL condition for the current assets and liabilities
 | 
			
		||||
        accounts.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def default_ie_account() -> IncomeExpensesAccount:
 | 
			
		||||
    """Returns the default account for the income and expenses log.
 | 
			
		||||
 | 
			
		||||
    :return: The default account for the income and expenses log.
 | 
			
		||||
    """
 | 
			
		||||
    code: str = default_ie_account_code()
 | 
			
		||||
    if code == IncomeExpensesAccount.CURRENT_AL_CODE:
 | 
			
		||||
        return IncomeExpensesAccount.current_assets_and_liabilities()
 | 
			
		||||
    return IncomeExpensesAccount(Account.find_by_code(code))
 | 
			
		||||
        :return: The SQL condition for the current assets and liabilities
 | 
			
		||||
            accounts.
 | 
			
		||||
        """
 | 
			
		||||
        return sa.or_(Account.base_code.startswith("11"),
 | 
			
		||||
                      Account.base_code.startswith("12"),
 | 
			
		||||
                      Account.base_code.startswith("21"),
 | 
			
		||||
                      Account.base_code.startswith("22"))
 | 
			
		||||
							
								
								
									
										225
									
								
								src/accounting/utils/options.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								src/accounting/utils/options.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,225 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
 | 
			
		||||
 | 
			
		||||
#  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 getter and setter for the option management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import Option, Account, Currency
 | 
			
		||||
from accounting.utils.current_account import CurrentAccount
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RecurringItem:
 | 
			
		||||
    """A recurring item."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, name: str, account_code: str,
 | 
			
		||||
                 description_template: str):
 | 
			
		||||
        """Constructs the recurring item.
 | 
			
		||||
 | 
			
		||||
        :param name: The name.
 | 
			
		||||
        :param account_code: The account code.
 | 
			
		||||
        :param description_template: The description template.
 | 
			
		||||
        """
 | 
			
		||||
        self.name: str = name
 | 
			
		||||
        self.account_code: str = account_code
 | 
			
		||||
        self.description_template: str = description_template
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_text(self) -> str:
 | 
			
		||||
        """Returns the account text.
 | 
			
		||||
 | 
			
		||||
        :return: The account text.
 | 
			
		||||
        """
 | 
			
		||||
        return str(Account.find_by_code(self.account_code))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Recurring:
 | 
			
		||||
    """The recurring expenses or incomes."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, data: dict[str, list[tuple[str, str, str]]]):
 | 
			
		||||
        """Constructs the recurring item.
 | 
			
		||||
 | 
			
		||||
        :param data: The data.
 | 
			
		||||
        """
 | 
			
		||||
        self.expenses: list[RecurringItem] \
 | 
			
		||||
            = [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]]
 | 
			
		||||
        self.incomes: list[RecurringItem] \
 | 
			
		||||
            = [RecurringItem(x[0], x[1], x[2]) for x in data["income"]]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def codes(self) -> set[str]:
 | 
			
		||||
        """Returns all the account codes.
 | 
			
		||||
 | 
			
		||||
        :return: All the account codes.
 | 
			
		||||
        """
 | 
			
		||||
        return {x.account_code for x in self.expenses + self.incomes}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Options:
 | 
			
		||||
    """The options."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        """Constructs the options."""
 | 
			
		||||
        self.is_modified: bool = False
 | 
			
		||||
        """Whether the options were modified."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_currency_code(self) -> str:
 | 
			
		||||
        """Returns the default currency code.
 | 
			
		||||
 | 
			
		||||
        :return: The default currency code.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__get_option("default_currency_code", "USD")
 | 
			
		||||
 | 
			
		||||
    @default_currency_code.setter
 | 
			
		||||
    def default_currency_code(self, value: str) -> None:
 | 
			
		||||
        """Sets the default currency code.
 | 
			
		||||
 | 
			
		||||
        :param value: The default currency code.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.__set_option("default_currency_code", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_currency_text(self) -> str:
 | 
			
		||||
        """Returns the text of the default currency code.
 | 
			
		||||
 | 
			
		||||
        :return: The text of the default currency code.
 | 
			
		||||
        """
 | 
			
		||||
        return str(db.session.get(Currency, self.default_currency_code))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_ie_account_code(self) -> str:
 | 
			
		||||
        """Returns the default account code for the income and expenses log.
 | 
			
		||||
 | 
			
		||||
        :return: The default account code for the income and expenses log.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__get_option("default_ie_account", Account.CASH_CODE)
 | 
			
		||||
 | 
			
		||||
    @default_ie_account_code.setter
 | 
			
		||||
    def default_ie_account_code(self, value: str) -> None:
 | 
			
		||||
        """Sets the default account code for the income and expenses log.
 | 
			
		||||
 | 
			
		||||
        :param value: The default account code for the income and expenses log.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.__set_option("default_ie_account", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_ie_account_code_text(self) -> str:
 | 
			
		||||
        """Returns the text of the default currency code.
 | 
			
		||||
 | 
			
		||||
        :return: The text of the default currency code.
 | 
			
		||||
        """
 | 
			
		||||
        code: str = self.default_ie_account_code
 | 
			
		||||
        if code == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return str(CurrentAccount.current_assets_and_liabilities())
 | 
			
		||||
        return str(CurrentAccount(Account.find_by_code(code)))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_ie_account(self) -> CurrentAccount:
 | 
			
		||||
        """Returns the default account code for the income and expenses log.
 | 
			
		||||
 | 
			
		||||
        :return: The default account code for the income and expenses log.
 | 
			
		||||
        """
 | 
			
		||||
        if self.default_ie_account_code \
 | 
			
		||||
                == CurrentAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return CurrentAccount.current_assets_and_liabilities()
 | 
			
		||||
        return CurrentAccount(
 | 
			
		||||
            Account.find_by_code(self.default_ie_account_code))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def recurring_data(self) -> dict[str, list[tuple[str, str, str]]]:
 | 
			
		||||
        """Returns the data of the recurring expenses and incomes.
 | 
			
		||||
 | 
			
		||||
        :return: The data of the recurring expenses and incomes.
 | 
			
		||||
        """
 | 
			
		||||
        json_data: str | None = self.__get_option("recurring")
 | 
			
		||||
        if json_data is None:
 | 
			
		||||
            return {"expense": [], "income": []}
 | 
			
		||||
        return json.loads(json_data)
 | 
			
		||||
 | 
			
		||||
    @recurring_data.setter
 | 
			
		||||
    def recurring_data(self,
 | 
			
		||||
                       value: dict[str, list[tuple[str, str, str]]]) -> None:
 | 
			
		||||
        """Sets the data of the recurring expenses and incomes.
 | 
			
		||||
 | 
			
		||||
        :param value: The data of the recurring expenses and incomes.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.__set_option("recurring", json.dumps(value, ensure_ascii=False,
 | 
			
		||||
                                                  separators=(",", ":")))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def recurring(self) -> Recurring:
 | 
			
		||||
        """Returns the recurring expenses and incomes.
 | 
			
		||||
 | 
			
		||||
        :return: The recurring expenses and incomes.
 | 
			
		||||
        """
 | 
			
		||||
        return Recurring(self.recurring_data)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __get_option(name: str, default: str | None = None) -> str:
 | 
			
		||||
        """Returns the value of an option.
 | 
			
		||||
 | 
			
		||||
        :param name: The name.
 | 
			
		||||
        :param default: The default value when the value does not exist.
 | 
			
		||||
        :return: The value.
 | 
			
		||||
        """
 | 
			
		||||
        option: Option | None = db.session.get(Option, name)
 | 
			
		||||
        if option is None:
 | 
			
		||||
            return default
 | 
			
		||||
        return option.value
 | 
			
		||||
 | 
			
		||||
    def __set_option(self, name: str, value: str) -> None:
 | 
			
		||||
        """Sets the value of an option.
 | 
			
		||||
 | 
			
		||||
        :param name: The name.
 | 
			
		||||
        :param value: The value.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        option: Option | None = db.session.get(Option, name)
 | 
			
		||||
        if option is None:
 | 
			
		||||
            current_user_pk: int = get_current_user_pk()
 | 
			
		||||
            db.session.add(Option(name=name,
 | 
			
		||||
                                  value=value,
 | 
			
		||||
                                  created_by_id=current_user_pk,
 | 
			
		||||
                                  updated_by_id=current_user_pk))
 | 
			
		||||
            self.is_modified = True
 | 
			
		||||
            return
 | 
			
		||||
        if option.value == value:
 | 
			
		||||
            return
 | 
			
		||||
        option.value = value
 | 
			
		||||
        option.updated_by_id = get_current_user_pk()
 | 
			
		||||
        option.updated_at = sa.func.now()
 | 
			
		||||
        self.is_modified = True
 | 
			
		||||
 | 
			
		||||
    def commit(self) -> None:
 | 
			
		||||
        """Commits the options to the database.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        db.session.commit()
 | 
			
		||||
        self.is_modified = False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
options: Options = Options()
 | 
			
		||||
"""The options."""
 | 
			
		||||
@@ -63,10 +63,13 @@ data."""
 | 
			
		||||
__can_edit_func: t.Callable[[], bool] = lambda: True
 | 
			
		||||
"""The callback that returns whether the current user can edit the accounting
 | 
			
		||||
data."""
 | 
			
		||||
__can_admin_func: t.Callable[[], bool] = lambda: True
 | 
			
		||||
"""The callback that returns whether the current user can administrate the
 | 
			
		||||
accounting settings."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def can_view() -> bool:
 | 
			
		||||
    """Returns whether the current user can view the account data.
 | 
			
		||||
    """Returns whether the current user can view the accounting data.
 | 
			
		||||
 | 
			
		||||
    :return: True if the current user can view the accounting data, or False
 | 
			
		||||
        otherwise.
 | 
			
		||||
@@ -75,7 +78,7 @@ def can_view() -> bool:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def can_edit() -> bool:
 | 
			
		||||
    """Returns whether the current user can edit the account data.
 | 
			
		||||
    """Returns whether the current user can edit the accounting data.
 | 
			
		||||
 | 
			
		||||
    The user has to log in.
 | 
			
		||||
 | 
			
		||||
@@ -87,6 +90,20 @@ def can_edit() -> bool:
 | 
			
		||||
    return __can_edit_func()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def can_admin() -> bool:
 | 
			
		||||
    """Returns whether the current user can administrate the accounting
 | 
			
		||||
    settings.
 | 
			
		||||
 | 
			
		||||
    The user has to log in.
 | 
			
		||||
 | 
			
		||||
    :return: True if the current user can administrate the accounting settings,
 | 
			
		||||
        or False otherwise.
 | 
			
		||||
    """
 | 
			
		||||
    if get_current_user() is None:
 | 
			
		||||
        return False
 | 
			
		||||
    return __can_admin_func()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
 | 
			
		||||
    """Initializes the application.
 | 
			
		||||
 | 
			
		||||
@@ -94,8 +111,10 @@ def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
 | 
			
		||||
    :param user_utils: The user utilities.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    global __can_view_func, __can_edit_func
 | 
			
		||||
    global __can_view_func, __can_edit_func, __can_admin_func
 | 
			
		||||
    __can_view_func = user_utils.can_view
 | 
			
		||||
    __can_edit_func = user_utils.can_edit
 | 
			
		||||
    __can_admin_func = user_utils.can_admin
 | 
			
		||||
    bp.add_app_template_global(user_utils.can_view, "accounting_can_view")
 | 
			
		||||
    bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit")
 | 
			
		||||
    bp.add_app_template_global(user_utils.can_admin, "accounting_can_admin")
 | 
			
		||||
 
 | 
			
		||||
@@ -50,6 +50,15 @@ class UserUtilityInterface(t.Generic[T], ABC):
 | 
			
		||||
            data, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def can_admin(self) -> bool:
 | 
			
		||||
        """Returns whether the currently logged-in user can administrate the
 | 
			
		||||
        accounting settings.
 | 
			
		||||
 | 
			
		||||
        :return: True if the currently logged-in user can administrate the
 | 
			
		||||
            accounting settings, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def cls(self) -> t.Type[T]:
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import unittest
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from datetime import timedelta, date
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -27,10 +27,8 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client, set_locale
 | 
			
		||||
 | 
			
		||||
NEXT_URI: str = "/_next"
 | 
			
		||||
"""The next URI."""
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountData:
 | 
			
		||||
@@ -53,13 +51,15 @@ class AccountData:
 | 
			
		||||
        """The code."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
cash: AccountData = AccountData("1111", 1, "Cash")
 | 
			
		||||
CASH: AccountData = AccountData("1111", 1, "Cash")
 | 
			
		||||
"""The cash account."""
 | 
			
		||||
bank: AccountData = AccountData("1113", 1, "Bank")
 | 
			
		||||
PETTY: AccountData = AccountData("1112", 1, "Bank")
 | 
			
		||||
"""The petty cash account."""
 | 
			
		||||
BANK: AccountData = AccountData("1113", 1, "Bank")
 | 
			
		||||
"""The bank account."""
 | 
			
		||||
stock: AccountData = AccountData("1121", 1, "Stock")
 | 
			
		||||
STOCK: AccountData = AccountData("1121", 1, "Stock")
 | 
			
		||||
"""The stock account."""
 | 
			
		||||
loan: AccountData = AccountData("2112", 1, "Loan")
 | 
			
		||||
LOAN: AccountData = AccountData("2112", 1, "Loan")
 | 
			
		||||
"""The loan account."""
 | 
			
		||||
PREFIX: str = "/accounting/accounts"
 | 
			
		||||
"""The URL prefix for the account management."""
 | 
			
		||||
@@ -147,19 +147,19 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": cash.title})
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": CASH.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{cash.code}")
 | 
			
		||||
                         f"{PREFIX}/{CASH.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": bank.base_code,
 | 
			
		||||
                                          "title": bank.title})
 | 
			
		||||
                                          "base_code": BANK.base_code,
 | 
			
		||||
                                          "title": BANK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{bank.code}")
 | 
			
		||||
                         f"{PREFIX}/{BANK.code}")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
@@ -173,7 +173,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{CASH.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/create")
 | 
			
		||||
@@ -181,33 +181,33 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/store",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "base_code": stock.base_code,
 | 
			
		||||
                                     "title": stock.title})
 | 
			
		||||
                                     "base_code": STOCK.base_code,
 | 
			
		||||
                                     "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}/edit")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{CASH.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{cash.code}/update",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{CASH.code}/update",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "base_code": cash.base_code,
 | 
			
		||||
                                     "title": f"{cash.title}-2"})
 | 
			
		||||
                                     "base_code": CASH.base_code,
 | 
			
		||||
                                     "title": f"{CASH.title}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{cash.code}/delete",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{BANK.code}/delete",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            bank_id: int = Account.find_by_code(bank.code).id
 | 
			
		||||
            cash_id: int = Account.find_by_code(CASH.code).id
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "next": NEXT_URI,
 | 
			
		||||
                                     f"{bank_id}-no": "5"})
 | 
			
		||||
                                     f"{cash_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
@@ -222,7 +222,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{CASH.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/create")
 | 
			
		||||
@@ -230,33 +230,33 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/store",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "base_code": stock.base_code,
 | 
			
		||||
                                     "title": stock.title})
 | 
			
		||||
                                     "base_code": STOCK.base_code,
 | 
			
		||||
                                     "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}/edit")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{CASH.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{cash.code}/update",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{CASH.code}/update",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "base_code": cash.base_code,
 | 
			
		||||
                                     "title": f"{cash.title}-2"})
 | 
			
		||||
                                     "base_code": CASH.base_code,
 | 
			
		||||
                                     "title": f"{CASH.title}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{cash.code}/delete",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{BANK.code}/delete",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            bank_id: int = Account.find_by_code(bank.code).id
 | 
			
		||||
            cash_id: int = Account.find_by_code(CASH.code).id
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "next": NEXT_URI,
 | 
			
		||||
                                     f"{bank_id}-no": "5"})
 | 
			
		||||
                                     f"{cash_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
@@ -270,7 +270,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{cash.code}")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{CASH.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/create")
 | 
			
		||||
@@ -278,37 +278,37 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{stock.code}")
 | 
			
		||||
                         f"{PREFIX}/{STOCK.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{cash.code}/edit")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{CASH.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{cash.code}/update",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{CASH.code}/update",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": f"{cash.title}-2"})
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": f"{CASH.title}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{cash.code}/delete",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], PREFIX)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/bases/{CASH.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            bank_id: int = Account.find_by_code(bank.code).id
 | 
			
		||||
            cash_id: int = Account.find_by_code(CASH.code).id
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": NEXT_URI,
 | 
			
		||||
                                          f"{bank_id}-no": "5"})
 | 
			
		||||
                                          f"{cash_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], NEXT_URI)
 | 
			
		||||
 | 
			
		||||
@@ -320,31 +320,31 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        create_uri: str = f"{PREFIX}/create"
 | 
			
		||||
        store_uri: str = f"{PREFIX}/store"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{stock.code}"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{STOCK.code}"
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {cash.code, bank.code})
 | 
			
		||||
                             {CASH.code, BANK.code})
 | 
			
		||||
 | 
			
		||||
        # Missing CSRF token
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                    data={"base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # CSRF token mismatch
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": f"{self.csrf_token}-2",
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # Empty base account code
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": " ",
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
@@ -352,7 +352,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "9999",
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
@@ -360,14 +360,14 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1",
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # Empty name
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": " "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
@@ -376,7 +376,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "6172",
 | 
			
		||||
                                          "title": stock.title,
 | 
			
		||||
                                          "title": STOCK.title,
 | 
			
		||||
                                          "is_need_offset": "yes"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
@@ -384,43 +384,43 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        # Success, with spaces to be stripped
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": f" {stock.base_code} ",
 | 
			
		||||
                                          "title": f" {stock.title} "})
 | 
			
		||||
                                          "base_code": f" {STOCK.base_code} ",
 | 
			
		||||
                                          "title": f" {STOCK.title} "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        # Success under the same base
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{stock.base_code}-002")
 | 
			
		||||
                         f"{PREFIX}/{STOCK.base_code}-002")
 | 
			
		||||
 | 
			
		||||
        # Success under the same base, with order in a mess.
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            stock_2: Account = Account.find_by_code(f"{stock.base_code}-002")
 | 
			
		||||
            stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002")
 | 
			
		||||
            stock_2.no = 66
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{stock.base_code}-003")
 | 
			
		||||
                         f"{PREFIX}/{STOCK.base_code}-003")
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {cash.code, bank.code, stock.code,
 | 
			
		||||
                              f"{stock.base_code}-002",
 | 
			
		||||
                              f"{stock.base_code}-003"})
 | 
			
		||||
                             {CASH.code, BANK.code, STOCK.code,
 | 
			
		||||
                              f"{STOCK.base_code}-002",
 | 
			
		||||
                              f"{STOCK.base_code}-003"})
 | 
			
		||||
 | 
			
		||||
            account: Account = Account.find_by_code(stock.code)
 | 
			
		||||
            self.assertEqual(account.base_code, stock.base_code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, stock.title)
 | 
			
		||||
            account: Account = Account.find_by_code(STOCK.code)
 | 
			
		||||
            self.assertEqual(account.base_code, STOCK.base_code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, STOCK.title)
 | 
			
		||||
 | 
			
		||||
    def test_basic_update(self) -> None:
 | 
			
		||||
        """Tests the basic rules to update a user.
 | 
			
		||||
@@ -428,30 +428,30 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{cash.code}/edit"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
			
		||||
        detail_c_uri: str = f"{PREFIX}/{stock.code}"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{CASH.code}"
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{CASH.code}/edit"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{CASH.code}/update"
 | 
			
		||||
        detail_c_uri: str = f"{PREFIX}/{STOCK.code}"
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        # Success, with spaces to be stripped
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": f" {cash.base_code} ",
 | 
			
		||||
                                          "title": f" {cash.title}-1 "})
 | 
			
		||||
                                          "base_code": f" {CASH.base_code} ",
 | 
			
		||||
                                          "title": f" {CASH.title}-1 "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account: Account = Account.find_by_code(cash.code)
 | 
			
		||||
            self.assertEqual(account.base_code, cash.base_code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, f"{cash.title}-1")
 | 
			
		||||
            account: Account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.base_code, CASH.base_code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, f"{CASH.title}-1")
 | 
			
		||||
 | 
			
		||||
        # Empty base account code
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": " ",
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
@@ -459,7 +459,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "9999",
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
@@ -467,14 +467,14 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1",
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # Empty name
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": " "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
@@ -483,7 +483,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "6172",
 | 
			
		||||
                                          "title": stock.title,
 | 
			
		||||
                                          "title": STOCK.title,
 | 
			
		||||
                                          "is_need_offset": "yes"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
@@ -491,8 +491,8 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        # Change the base account
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_c_uri)
 | 
			
		||||
 | 
			
		||||
@@ -508,20 +508,20 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{CASH.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{CASH.code}/update"
 | 
			
		||||
        account: Account
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": f" {cash.base_code} ",
 | 
			
		||||
                                          "title": f" {cash.title} "})
 | 
			
		||||
                                          "base_code": f" {CASH.base_code} ",
 | 
			
		||||
                                          "title": f" {CASH.title} "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertIsNotNone(account)
 | 
			
		||||
            account.created_at \
 | 
			
		||||
                = account.created_at - timedelta(seconds=5)
 | 
			
		||||
@@ -530,13 +530,13 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertIsNotNone(account)
 | 
			
		||||
            self.assertLess(account.created_at,
 | 
			
		||||
                            account.updated_at)
 | 
			
		||||
@@ -549,25 +549,25 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        editor_username, editor2_username = "editor", "editor2"
 | 
			
		||||
        client, csrf_token = get_client(self.app, editor2_username)
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{CASH.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{CASH.code}/update"
 | 
			
		||||
        account: Account
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.created_by.username, editor_username)
 | 
			
		||||
            self.assertEqual(account.updated_by.username, editor_username)
 | 
			
		||||
 | 
			
		||||
        response = client.post(update_uri,
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "base_code": cash.base_code,
 | 
			
		||||
                                     "title": f"{cash.title}-2"})
 | 
			
		||||
                                     "base_code": CASH.base_code,
 | 
			
		||||
                                     "title": f"{CASH.title}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.created_by.username,
 | 
			
		||||
                             editor_username)
 | 
			
		||||
            self.assertEqual(account.updated_by.username,
 | 
			
		||||
@@ -579,60 +579,60 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{CASH.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{CASH.code}/update"
 | 
			
		||||
        account: Account
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, cash.title)
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, CASH.title)
 | 
			
		||||
            self.assertEqual(account.l10n, [])
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "zh_Hant")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": f"{cash.title}-zh_Hant"})
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": f"{CASH.title}-zh_Hant"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, cash.title)
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, CASH.title)
 | 
			
		||||
            self.assertEqual({(x.locale, x.title) for x in account.l10n},
 | 
			
		||||
                             {("zh_Hant", f"{cash.title}-zh_Hant")})
 | 
			
		||||
                             {("zh_Hant", f"{CASH.title}-zh_Hant")})
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "en")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": f"{cash.title}-2"})
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": f"{CASH.title}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, f"{cash.title}-2")
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, f"{CASH.title}-2")
 | 
			
		||||
            self.assertEqual({(x.locale, x.title) for x in account.l10n},
 | 
			
		||||
                             {("zh_Hant", f"{cash.title}-zh_Hant")})
 | 
			
		||||
                             {("zh_Hant", f"{CASH.title}-zh_Hant")})
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "zh_Hant")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": f"{cash.title}-zh_Hant-2"})
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": f"{CASH.title}-zh_Hant-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account = Account.find_by_code(cash.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, f"{cash.title}-2")
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.title_l10n, f"{CASH.title}-2")
 | 
			
		||||
            self.assertEqual({(x.locale, x.title) for x in account.l10n},
 | 
			
		||||
                             {("zh_Hant", f"{cash.title}-zh_Hant-2")})
 | 
			
		||||
                             {("zh_Hant", f"{CASH.title}-zh_Hant-2")})
 | 
			
		||||
 | 
			
		||||
    def test_delete(self) -> None:
 | 
			
		||||
        """Tests to delete a currency.
 | 
			
		||||
@@ -640,15 +640,51 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
			
		||||
        delete_uri: str = f"{PREFIX}/{cash.code}/delete"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{PETTY.code}"
 | 
			
		||||
        delete_uri: str = f"{PREFIX}/{PETTY.code}/delete"
 | 
			
		||||
        list_uri: str = PREFIX
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": PETTY.base_code,
 | 
			
		||||
                                          "title": PETTY.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/currencies/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": "USD",
 | 
			
		||||
                                          "name": "US Dollars"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/currencies/USD")
 | 
			
		||||
 | 
			
		||||
        add_journal_entry(self.client,
 | 
			
		||||
                          form={"csrf_token": self.csrf_token,
 | 
			
		||||
                                "next": NEXT_URI,
 | 
			
		||||
                                "date": date.today().isoformat(),
 | 
			
		||||
                                "currency-1-code": "USD",
 | 
			
		||||
                                "currency-1-credit-1-account_code": BANK.code,
 | 
			
		||||
                                "currency-1-credit-1-amount": "20"})
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {cash.code, bank.code})
 | 
			
		||||
                             {CASH.code, PETTY.code, BANK.code})
 | 
			
		||||
 | 
			
		||||
        # Cannot delete the cash account
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{CASH.code}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
 | 
			
		||||
 | 
			
		||||
        # Cannot delete the account that is in use
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}")
 | 
			
		||||
 | 
			
		||||
        # Success
 | 
			
		||||
        response = self.client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        response = self.client.post(delete_uri,
 | 
			
		||||
@@ -658,7 +694,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {bank.code})
 | 
			
		||||
                             {CASH.code, BANK.code})
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 404)
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@
 | 
			
		||||
import csv
 | 
			
		||||
import typing as t
 | 
			
		||||
import unittest
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from datetime import timedelta, date
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from click.testing import Result
 | 
			
		||||
@@ -28,7 +28,8 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client, set_locale
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyData:
 | 
			
		||||
@@ -46,14 +47,14 @@ class CurrencyData:
 | 
			
		||||
        """The name."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
 | 
			
		||||
"""The first test currency."""
 | 
			
		||||
zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
 | 
			
		||||
"""The second test currency."""
 | 
			
		||||
zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
 | 
			
		||||
"""The third test currency."""
 | 
			
		||||
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
 | 
			
		||||
"""The fourth test currency."""
 | 
			
		||||
USD: CurrencyData = CurrencyData("USD", "US Dollar")
 | 
			
		||||
"""The US dollars."""
 | 
			
		||||
EUR: CurrencyData = CurrencyData("EUR", "Euro")
 | 
			
		||||
"""The European dollars."""
 | 
			
		||||
TWD: CurrencyData = CurrencyData("TWD", "Taiwan dollars")
 | 
			
		||||
"""The Taiwan dollars."""
 | 
			
		||||
JPY: CurrencyData = CurrencyData("JPY", "Japanese yen")
 | 
			
		||||
"""The Japanese yen."""
 | 
			
		||||
PREFIX: str = "/accounting/currencies"
 | 
			
		||||
"""The URL prefix for the currency management."""
 | 
			
		||||
 | 
			
		||||
@@ -140,17 +141,17 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zza.code,
 | 
			
		||||
                                          "name": zza.name})
 | 
			
		||||
                                          "code": USD.code,
 | 
			
		||||
                                          "name": USD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzb.code,
 | 
			
		||||
                                          "name": zzb.name})
 | 
			
		||||
                                          "code": EUR.code,
 | 
			
		||||
                                          "name": EUR.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
@@ -163,7 +164,7 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{zza.code}")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{USD.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/create")
 | 
			
		||||
@@ -171,20 +172,20 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/store",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "code": zzc.code,
 | 
			
		||||
                                     "name": zzc.name})
 | 
			
		||||
                                     "code": TWD.code,
 | 
			
		||||
                                     "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{zza.code}/edit")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{USD.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{zza.code}/update",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{USD.code}/update",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "code": zzd.code,
 | 
			
		||||
                                     "name": zzd.name})
 | 
			
		||||
                                     "code": JPY.code,
 | 
			
		||||
                                     "name": JPY.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{zzb.code}/delete",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{EUR.code}/delete",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
@@ -199,7 +200,7 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{zza.code}")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{USD.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/create")
 | 
			
		||||
@@ -207,20 +208,20 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/store",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "code": zzc.code,
 | 
			
		||||
                                     "name": zzc.name})
 | 
			
		||||
                                     "code": TWD.code,
 | 
			
		||||
                                     "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{zza.code}/edit")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{USD.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{zza.code}/update",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{USD.code}/update",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "code": zzd.code,
 | 
			
		||||
                                     "name": zzd.name})
 | 
			
		||||
                                     "code": JPY.code,
 | 
			
		||||
                                     "name": JPY.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{zzb.code}/delete",
 | 
			
		||||
        response = client.post(f"{PREFIX}/{EUR.code}/delete",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
@@ -234,7 +235,7 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{zza.code}")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{USD.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/create")
 | 
			
		||||
@@ -242,22 +243,22 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzc.code,
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "code": TWD.code,
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{TWD.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{zza.code}/edit")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{USD.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{zza.code}/update",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{USD.code}/update",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzd.code,
 | 
			
		||||
                                          "name": zzd.name})
 | 
			
		||||
                                          "code": JPY.code,
 | 
			
		||||
                                          "name": JPY.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{JPY.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], PREFIX)
 | 
			
		||||
@@ -270,31 +271,31 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import Currency
 | 
			
		||||
        create_uri: str = f"{PREFIX}/create"
 | 
			
		||||
        store_uri: str = f"{PREFIX}/store"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{zzc.code}"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{TWD.code}"
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Currency.query.all()},
 | 
			
		||||
                             {zza.code, zzb.code})
 | 
			
		||||
                             {USD.code, EUR.code})
 | 
			
		||||
 | 
			
		||||
        # Missing CSRF token
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"code": zzc.code,
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                    data={"code": TWD.code,
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # CSRF token mismatch
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": f"{self.csrf_token}-2",
 | 
			
		||||
                                          "code": zzc.code,
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "code": TWD.code,
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        # Empty code
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": " ",
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
@@ -302,7 +303,7 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": " create ",
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
@@ -310,14 +311,14 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": " zzc ",
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # Empty name
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzc.code,
 | 
			
		||||
                                          "code": TWD.code,
 | 
			
		||||
                                          "name": " "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
@@ -325,26 +326,26 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        # Success, with spaces to be stripped
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": f" {zzc.code} ",
 | 
			
		||||
                                          "name": f" {zzc.name} "})
 | 
			
		||||
                                          "code": f" {TWD.code} ",
 | 
			
		||||
                                          "name": f" {TWD.name} "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        # Duplicated code
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzc.code,
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "code": TWD.code,
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Currency.query.all()},
 | 
			
		||||
                             {zza.code, zzb.code, zzc.code})
 | 
			
		||||
                             {USD.code, EUR.code, TWD.code})
 | 
			
		||||
 | 
			
		||||
            currency: Currency = db.session.get(Currency, zzc.code)
 | 
			
		||||
            self.assertEqual(currency.code, zzc.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, zzc.name)
 | 
			
		||||
            currency: Currency = db.session.get(Currency, TWD.code)
 | 
			
		||||
            self.assertEqual(currency.code, TWD.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, TWD.name)
 | 
			
		||||
 | 
			
		||||
    def test_basic_update(self) -> None:
 | 
			
		||||
        """Tests the basic rules to update a user.
 | 
			
		||||
@@ -352,30 +353,30 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Currency
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{zza.code}/edit"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
			
		||||
        detail_c_uri: str = f"{PREFIX}/{zzc.code}"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{USD.code}"
 | 
			
		||||
        edit_uri: str = f"{PREFIX}/{USD.code}/edit"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{USD.code}/update"
 | 
			
		||||
        detail_c_uri: str = f"{PREFIX}/{TWD.code}"
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        # Success, with spaces to be stripped
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": f" {zza.code} ",
 | 
			
		||||
                                          "name": f" {zza.name}-1 "})
 | 
			
		||||
                                          "code": f" {USD.code} ",
 | 
			
		||||
                                          "name": f" {USD.name}-1 "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency: Currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            self.assertEqual(currency.code, zza.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, f"{zza.name}-1")
 | 
			
		||||
            currency: Currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertEqual(currency.code, USD.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, f"{USD.name}-1")
 | 
			
		||||
 | 
			
		||||
        # Empty code
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": " ",
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
@@ -383,7 +384,7 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": " create ",
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
@@ -391,14 +392,14 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": "abc/def",
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # Empty name
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzc.code,
 | 
			
		||||
                                          "code": TWD.code,
 | 
			
		||||
                                          "name": " "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
@@ -406,16 +407,16 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        # Duplicated code
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzb.code,
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "code": EUR.code,
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # Change code
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zzc.code,
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "code": TWD.code,
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_c_uri)
 | 
			
		||||
 | 
			
		||||
@@ -431,20 +432,20 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Currency
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{USD.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{USD.code}/update"
 | 
			
		||||
        currency: Currency | None
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": f" {zza.code} ",
 | 
			
		||||
                                          "name": f" {zza.name} "})
 | 
			
		||||
                                          "code": f" {USD.code} ",
 | 
			
		||||
                                          "name": f" {USD.name} "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertIsNotNone(currency)
 | 
			
		||||
            currency.created_at \
 | 
			
		||||
                = currency.created_at - timedelta(seconds=5)
 | 
			
		||||
@@ -453,13 +454,13 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zza.code,
 | 
			
		||||
                                          "name": zzc.name})
 | 
			
		||||
                                          "code": USD.code,
 | 
			
		||||
                                          "name": TWD.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertIsNotNone(currency)
 | 
			
		||||
            self.assertLess(currency.created_at,
 | 
			
		||||
                            currency.updated_at)
 | 
			
		||||
@@ -472,25 +473,25 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import Currency
 | 
			
		||||
        editor_username, editor2_username = "editor", "editor2"
 | 
			
		||||
        client, csrf_token = get_client(self.app, editor2_username)
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{USD.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{USD.code}/update"
 | 
			
		||||
        currency: Currency
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertEqual(currency.created_by.username, editor_username)
 | 
			
		||||
            self.assertEqual(currency.updated_by.username, editor_username)
 | 
			
		||||
 | 
			
		||||
        response = client.post(update_uri,
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "code": zza.code,
 | 
			
		||||
                                     "name": f"{zza.name}-2"})
 | 
			
		||||
                                     "code": USD.code,
 | 
			
		||||
                                     "name": f"{USD.name}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertEqual(currency.created_by.username, editor_username)
 | 
			
		||||
            self.assertEqual(currency.updated_by.username, editor2_username)
 | 
			
		||||
 | 
			
		||||
@@ -502,14 +503,14 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"/accounting/api/currencies/exists-code?q={zza.code}")
 | 
			
		||||
            f"/accounting/api/currencies/exists-code?q={USD.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        data = response.json()
 | 
			
		||||
        self.assertEqual(set(data.keys()), {"exists"})
 | 
			
		||||
        self.assertTrue(data["exists"])
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"/accounting/api/currencies/exists-code?q={zza.code}-1")
 | 
			
		||||
            f"/accounting/api/currencies/exists-code?q={USD.code}-1")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        data = response.json()
 | 
			
		||||
        self.assertEqual(set(data.keys()), {"exists"})
 | 
			
		||||
@@ -521,60 +522,60 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Currency
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{USD.code}"
 | 
			
		||||
        update_uri: str = f"{PREFIX}/{USD.code}/update"
 | 
			
		||||
        currency: Currency
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, zza.name)
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, USD.name)
 | 
			
		||||
            self.assertEqual(currency.l10n, [])
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "zh_Hant")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zza.code,
 | 
			
		||||
                                          "name": f"{zza.name}-zh_Hant"})
 | 
			
		||||
                                          "code": USD.code,
 | 
			
		||||
                                          "name": f"{USD.name}-zh_Hant"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, zza.name)
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, USD.name)
 | 
			
		||||
            self.assertEqual({(x.locale, x.name) for x in currency.l10n},
 | 
			
		||||
                             {("zh_Hant", f"{zza.name}-zh_Hant")})
 | 
			
		||||
                             {("zh_Hant", f"{USD.name}-zh_Hant")})
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "en")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zza.code,
 | 
			
		||||
                                          "name": f"{zza.name}-2"})
 | 
			
		||||
                                          "code": USD.code,
 | 
			
		||||
                                          "name": f"{USD.name}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, f"{zza.name}-2")
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, f"{USD.name}-2")
 | 
			
		||||
            self.assertEqual({(x.locale, x.name) for x in currency.l10n},
 | 
			
		||||
                             {("zh_Hant", f"{zza.name}-zh_Hant")})
 | 
			
		||||
                             {("zh_Hant", f"{USD.name}-zh_Hant")})
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "zh_Hant")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": zza.code,
 | 
			
		||||
                                          "name": f"{zza.name}-zh_Hant-2"})
 | 
			
		||||
                                          "code": USD.code,
 | 
			
		||||
                                          "name": f"{USD.name}-zh_Hant-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            currency = db.session.get(Currency, zza.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, f"{zza.name}-2")
 | 
			
		||||
            currency = db.session.get(Currency, USD.code)
 | 
			
		||||
            self.assertEqual(currency.name_l10n, f"{USD.name}-2")
 | 
			
		||||
            self.assertEqual({(x.locale, x.name) for x in currency.l10n},
 | 
			
		||||
                             {("zh_Hant", f"{zza.name}-zh_Hant-2")})
 | 
			
		||||
                             {("zh_Hant", f"{USD.name}-zh_Hant-2")})
 | 
			
		||||
 | 
			
		||||
    def test_delete(self) -> None:
 | 
			
		||||
        """Tests to delete a currency.
 | 
			
		||||
@@ -582,15 +583,58 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Currency
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
			
		||||
        delete_uri: str = f"{PREFIX}/{zza.code}/delete"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{JPY.code}"
 | 
			
		||||
        delete_uri: str = f"{PREFIX}/{JPY.code}/delete"
 | 
			
		||||
        list_uri: str = PREFIX
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            from accounting.models import BaseAccount
 | 
			
		||||
            if BaseAccount.query.first() is None:
 | 
			
		||||
                result = runner.invoke(args="accounting-init-base")
 | 
			
		||||
                self.assertEqual(result.exit_code, 0)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1111",
 | 
			
		||||
                                          "title": "Cash"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1111-001")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "code": JPY.code,
 | 
			
		||||
                                          "name": JPY.name})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        add_journal_entry(self.client,
 | 
			
		||||
                          form={"csrf_token": self.csrf_token,
 | 
			
		||||
                                "next": NEXT_URI,
 | 
			
		||||
                                "date": date.today().isoformat(),
 | 
			
		||||
                                "currency-1-code": EUR.code,
 | 
			
		||||
                                "currency-1-credit-1-account_code": "1111-001",
 | 
			
		||||
                                "currency-1-credit-1-amount": "20"})
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Currency.query.all()},
 | 
			
		||||
                             {zza.code, zzb.code})
 | 
			
		||||
                             {USD.code, EUR.code, JPY.code})
 | 
			
		||||
 | 
			
		||||
        # Cannot delete the default currency
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{USD.code}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
 | 
			
		||||
 | 
			
		||||
        # Cannot delete the account that is in use
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
 | 
			
		||||
 | 
			
		||||
        # Success
 | 
			
		||||
        response = self.client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        response = self.client.post(delete_uri,
 | 
			
		||||
@@ -600,7 +644,7 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Currency.query.all()},
 | 
			
		||||
                             {zzb.code})
 | 
			
		||||
                             {USD.code, EUR.code})
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 404)
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,8 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from testlib import create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import Accounts, NEXT_URI, add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DescriptionEditorTestCase(unittest.TestCase):
 | 
			
		||||
 
 | 
			
		||||
@@ -27,9 +27,9 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import NEXT_URI, NON_EMPTY_NOTE, EMPTY_NOTE, \
 | 
			
		||||
    Accounts, get_add_form, get_unchanged_update_form, get_update_form, \
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \
 | 
			
		||||
    get_add_form, get_unchanged_update_form, get_update_form, \
 | 
			
		||||
    match_journal_entry_detail, set_negative_amount, \
 | 
			
		||||
    remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry
 | 
			
		||||
 | 
			
		||||
@@ -309,7 +309,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertEqual(len(currencies[2].credit), 2)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[0].no, 6)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[0].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[1].no, 7)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[1].account.code,
 | 
			
		||||
                             Accounts.DONATION)
 | 
			
		||||
@@ -441,7 +441,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertNotIn(currencies1[0].credit[1].id, old_id)
 | 
			
		||||
            self.assertEqual(currencies1[0].credit[1].no, 2)
 | 
			
		||||
            self.assertEqual(currencies1[0].credit[1].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(currencies1[1].code, "EUR")
 | 
			
		||||
            self.assertEqual(len(currencies1[1].debit), 1)
 | 
			
		||||
@@ -457,7 +457,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
                             currencies0[2].credit[0].id)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[0].no, 3)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[0].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[1].id,
 | 
			
		||||
                             currencies0[2].credit[1].id)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[1].no, 4)
 | 
			
		||||
@@ -569,12 +569,43 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        journal_entry_id: int \
 | 
			
		||||
        from accounting.models import JournalEntry, JournalEntryLineItem
 | 
			
		||||
        journal_entry_id_1: int \
 | 
			
		||||
            = add_journal_entry(self.client, self.__get_add_form())
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
 | 
			
		||||
        delete_uri: str = f"{PREFIX}/{journal_entry_id}/delete"
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{journal_entry_id_1}?next=%2F_next"
 | 
			
		||||
        delete_uri: str = f"{PREFIX}/{journal_entry_id_1}/delete"
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        form: dict[str, str] = self.__get_add_form()
 | 
			
		||||
        key: str = [x for x in form if x.endswith("-account_code")][0]
 | 
			
		||||
        form[key] = Accounts.PAYABLE
 | 
			
		||||
        journal_entry_id_2: int = add_journal_entry(self.client, form)
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            journal_entry: JournalEntry | None \
 | 
			
		||||
                = db.session.get(JournalEntry, journal_entry_id_2)
 | 
			
		||||
            self.assertIsNotNone(journal_entry)
 | 
			
		||||
            line_item: JournalEntryLineItem \
 | 
			
		||||
                = [x for x in journal_entry.line_items
 | 
			
		||||
                   if x.account_code == Accounts.PAYABLE][0]
 | 
			
		||||
        add_journal_entry(
 | 
			
		||||
            self.client,
 | 
			
		||||
            form={"csrf_token": self.csrf_token,
 | 
			
		||||
                  "next": NEXT_URI,
 | 
			
		||||
                  "date": date.today().isoformat(),
 | 
			
		||||
                  "currency-1-code": line_item.currency_code,
 | 
			
		||||
                  "currency-1-debit-1-original_line_item_id": line_item.id,
 | 
			
		||||
                  "currency-1-debit-1-account_code": line_item.account_code,
 | 
			
		||||
                  "currency-1-debit-1-amount": "1"})
 | 
			
		||||
 | 
			
		||||
        # Cannot delete the journal entry that is in use
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{journal_entry_id_2}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": NEXT_URI})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{journal_entry_id_2}?next=%2F_next")
 | 
			
		||||
 | 
			
		||||
        # Success
 | 
			
		||||
        response = self.client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        response = self.client.post(delete_uri,
 | 
			
		||||
@@ -1531,7 +1562,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertEqual(len(currencies[2].credit), 2)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[0].no, 6)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[0].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[1].no, 7)
 | 
			
		||||
            self.assertEqual(currencies[2].credit[1].account.code,
 | 
			
		||||
                             Accounts.DONATION)
 | 
			
		||||
@@ -1697,7 +1728,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertNotIn(currencies1[0].credit[1].id, old_id)
 | 
			
		||||
            self.assertEqual(currencies1[0].credit[1].no, 2)
 | 
			
		||||
            self.assertEqual(currencies1[0].credit[1].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(currencies1[1].code, "EUR")
 | 
			
		||||
            self.assertEqual(len(currencies1[1].debit), 2)
 | 
			
		||||
@@ -1717,7 +1748,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
                             currencies0[2].credit[0].id)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[0].no, 3)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[0].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[1].id,
 | 
			
		||||
                             currencies0[2].credit[1].id)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[1].no, 4)
 | 
			
		||||
@@ -1883,7 +1914,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertNotIn(currencies1[0].credit[1].id, old_id)
 | 
			
		||||
            self.assertEqual(currencies1[0].credit[1].no, 2)
 | 
			
		||||
            self.assertEqual(currencies1[0].credit[1].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(currencies1[1].code, "EUR")
 | 
			
		||||
            self.assertEqual(len(currencies1[1].debit), 1)
 | 
			
		||||
@@ -1899,7 +1930,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
 | 
			
		||||
                             currencies0[2].credit[0].id)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[0].no, 3)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[0].account.code,
 | 
			
		||||
                             Accounts.RENT)
 | 
			
		||||
                             Accounts.RENT_INCOME)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[1].id,
 | 
			
		||||
                             currencies0[2].credit[1].id)
 | 
			
		||||
            self.assertEqual(currencies1[1].credit[1].no, 4)
 | 
			
		||||
 
 | 
			
		||||
@@ -26,8 +26,8 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import Accounts, match_journal_entry_detail
 | 
			
		||||
from testlib import Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import match_journal_entry_detail
 | 
			
		||||
from testlib_offset import TestData, JournalEntryLineItemData, \
 | 
			
		||||
    JournalEntryData, CurrencyData
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										416
									
								
								tests/test_option.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										416
									
								
								tests/test_option.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,416 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
 | 
			
		||||
 | 
			
		||||
#  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 options.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import unittest
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_offset import TestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/options"
 | 
			
		||||
"""The URL prefix for the option management."""
 | 
			
		||||
DETAIL_URI: str = f"{PREFIX}?next=%2F_next"
 | 
			
		||||
"""THE URI for the option detail."""
 | 
			
		||||
EDIT_URI: str = f"{PREFIX}/edit?next=%2F_next"
 | 
			
		||||
"""THE URI for the form to edit the options."""
 | 
			
		||||
UPDATE_URI: str = f"{PREFIX}/update"
 | 
			
		||||
"""THE URI to update the options."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OptionTestCase(unittest.TestCase):
 | 
			
		||||
    """The option 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, Option
 | 
			
		||||
            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)
 | 
			
		||||
            Option.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_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "nobody")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(DETAIL_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(EDIT_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
        """Test the permission as viewer.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "viewer")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(DETAIL_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(EDIT_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_editor2(self) -> None:
 | 
			
		||||
        """Test the permission as non-administrator.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "editor2")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(DETAIL_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(EDIT_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
        """Test the permission as administrator.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(DETAIL_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(EDIT_URI)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=self.__get_form())
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
    def test_set(self) -> None:
 | 
			
		||||
        """Test to set the options.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.utils.options import options
 | 
			
		||||
        form: dict[str, str]
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        # Empty currency code
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        form["default_currency_code"] = " "
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Non-existing currency code
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        form["default_currency_code"] = "ZZZ"
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Empty current account
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        form["default_ie_account_code"] = " "
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Non-existing current account
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        form["default_ie_account_code"] = "9999-999"
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Not a current account
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        form["default_ie_account_code"] = Accounts.MEAL
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Recurring item name empty
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key = [x for x in form if x.endswith("-name")][0]
 | 
			
		||||
        form[key] = " "
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Recurring item account empty
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key = [x for x in form if x.endswith("-account_code")][0]
 | 
			
		||||
        form[key] = " "
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Recurring item non-expense account
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key = [x for x in form
 | 
			
		||||
               if x.startswith("recurring-expense-")
 | 
			
		||||
               and x.endswith("-account_code")][0]
 | 
			
		||||
        form[key] = Accounts.SERVICE
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Recurring item non-income account
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key = [x for x in form
 | 
			
		||||
               if x.startswith("recurring-income-")
 | 
			
		||||
               and x.endswith("-account_code")][0]
 | 
			
		||||
        form[key] = Accounts.UTILITIES
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Recurring item payable expense
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key = [x for x in form
 | 
			
		||||
               if x.startswith("recurring-expense-")
 | 
			
		||||
               and x.endswith("-account_code")][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)
 | 
			
		||||
 | 
			
		||||
        # Recurring item receivable income
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key = [x for x in form
 | 
			
		||||
               if x.startswith("recurring-income-")
 | 
			
		||||
               and x.endswith("-account_code")][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)
 | 
			
		||||
 | 
			
		||||
        # Recurring item description template empty
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key = [x for x in form if x.endswith("-description_template")][0]
 | 
			
		||||
        form[key] = " "
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], EDIT_URI)
 | 
			
		||||
 | 
			
		||||
        # Success, with malformed order
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual(options.default_currency_code, "USD")
 | 
			
		||||
            self.assertEqual(options.default_ie_account_code, "1111-001")
 | 
			
		||||
            self.assertEqual(len(options.recurring.expenses), 0)
 | 
			
		||||
            self.assertEqual(len(options.recurring.incomes), 0)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=self.__get_form())
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual(options.default_currency_code, "EUR")
 | 
			
		||||
            self.assertEqual(options.default_ie_account_code, "0000-000")
 | 
			
		||||
            self.assertEqual(len(options.recurring.expenses), 4)
 | 
			
		||||
            self.assertEqual(options.recurring.expenses[0].account_code,
 | 
			
		||||
                             Accounts.INSURANCE)
 | 
			
		||||
            self.assertEqual(options.recurring.expenses[1].account_code,
 | 
			
		||||
                             Accounts.POSTAGE)
 | 
			
		||||
            self.assertEqual(options.recurring.expenses[2].account_code,
 | 
			
		||||
                             Accounts.UTILITIES)
 | 
			
		||||
            self.assertEqual(options.recurring.expenses[3].account_code,
 | 
			
		||||
                             Accounts.RENT_EXPENSE)
 | 
			
		||||
            self.assertEqual(len(options.recurring.incomes), 2)
 | 
			
		||||
            self.assertEqual(options.recurring.incomes[0].account_code,
 | 
			
		||||
                             Accounts.SERVICE)
 | 
			
		||||
            self.assertEqual(options.recurring.incomes[1].account_code,
 | 
			
		||||
                             Accounts.DONATION)
 | 
			
		||||
 | 
			
		||||
        # Success, with no recurring data
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        form = {x: form[x] for x in form if not x.startswith("recurring-")}
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual(len(options.recurring.expenses), 0)
 | 
			
		||||
            self.assertEqual(len(options.recurring.incomes), 0)
 | 
			
		||||
 | 
			
		||||
    def test_update_not_modified(self) -> None:
 | 
			
		||||
        """Tests that the data is not modified.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Option
 | 
			
		||||
        form: dict[str, str]
 | 
			
		||||
        option: Option | None
 | 
			
		||||
        resource: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=self.__get_form())
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            option = db.session.get(Option, "recurring")
 | 
			
		||||
            self.assertIsNotNone(option)
 | 
			
		||||
            timestamp: datetime = option.created_at - timedelta(seconds=5)
 | 
			
		||||
            option.created_at = timestamp
 | 
			
		||||
            option.updated_at = timestamp
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        # The recurring setting was not modified
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        form["default_currency_code"] = "JPY"
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            option = db.session.get(Option, "recurring")
 | 
			
		||||
            self.assertIsNotNone(option)
 | 
			
		||||
            self.assertEqual(option.created_at, timestamp)
 | 
			
		||||
            self.assertEqual(option.updated_at, timestamp)
 | 
			
		||||
 | 
			
		||||
        # The recurring setting was modified
 | 
			
		||||
        form = self.__get_form()
 | 
			
		||||
        key: str = [x for x in form
 | 
			
		||||
                    if x.startswith("recurring-expense-")
 | 
			
		||||
                    and x.endswith("-account_code")][0]
 | 
			
		||||
        form[key] = Accounts.MEAL
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            option = db.session.get(Option, "recurring")
 | 
			
		||||
            self.assertIsNotNone(option)
 | 
			
		||||
            self.assertLess(option.created_at, option.updated_at)
 | 
			
		||||
 | 
			
		||||
    def test_created_updated_by(self) -> None:
 | 
			
		||||
        """Tests the created-by and updated-by record.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Option
 | 
			
		||||
        from accounting.utils.user import get_user_pk
 | 
			
		||||
        editor_username, editor2_username = "editor", "editor2"
 | 
			
		||||
        option: Option | None
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=self.__get_form())
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            editor2_pk: int = get_user_pk(editor2_username)
 | 
			
		||||
            option = db.session.get(Option, "recurring")
 | 
			
		||||
            self.assertIsNotNone(option)
 | 
			
		||||
            option.created_by_id = editor2_pk
 | 
			
		||||
            option.updated_by_id = editor2_pk
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        form: dict[str, str] = self.__get_form()
 | 
			
		||||
        key: str = [x for x in form
 | 
			
		||||
                    if x.startswith("recurring-expense-")
 | 
			
		||||
                    and x.endswith("-account_code")][0]
 | 
			
		||||
        form[key] = Accounts.MEAL
 | 
			
		||||
        response = self.client.post(UPDATE_URI, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], DETAIL_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            option = db.session.get(Option, "recurring")
 | 
			
		||||
            self.assertIsNotNone(option)
 | 
			
		||||
            self.assertEqual(option.created_by.username, editor2_username)
 | 
			
		||||
            self.assertEqual(option.updated_by.username, editor_username)
 | 
			
		||||
 | 
			
		||||
    def __get_form(self, csrf_token: str | None = None) -> dict[str, str]:
 | 
			
		||||
        """Returns the option form.
 | 
			
		||||
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        :return: The option form.
 | 
			
		||||
        """
 | 
			
		||||
        if csrf_token is None:
 | 
			
		||||
            csrf_token = self.csrf_token
 | 
			
		||||
        return {"csrf_token": csrf_token,
 | 
			
		||||
                "next": NEXT_URI,
 | 
			
		||||
                "default_currency_code": "EUR",
 | 
			
		||||
                "default_ie_account_code": "0000-000",
 | 
			
		||||
                "recurring-expense-1-name": "Water bill",
 | 
			
		||||
                "recurring-expense-1-account_code": Accounts.UTILITIES,
 | 
			
		||||
                "recurring-expense-1-description_template":
 | 
			
		||||
                    "Water bill for {last_bimonthly_name}",
 | 
			
		||||
                "recurring-expense-3-no": "16",
 | 
			
		||||
                "recurring-expense-3-name": "Phone bill",
 | 
			
		||||
                "recurring-expense-3-account_code": Accounts.POSTAGE,
 | 
			
		||||
                "recurring-expense-3-description_template":
 | 
			
		||||
                    "Phone bill for {last_month_name}",
 | 
			
		||||
                "recurring-expense-12-name": "Rent",
 | 
			
		||||
                "recurring-expense-12-account_code": Accounts.RENT_EXPENSE,
 | 
			
		||||
                "recurring-expense-12-description_template":
 | 
			
		||||
                    "Rent for {this_month_name}",
 | 
			
		||||
                "recurring-expense-26-no": "7",
 | 
			
		||||
                "recurring-expense-26-name": "Insurance",
 | 
			
		||||
                "recurring-expense-26-account_code": Accounts.INSURANCE,
 | 
			
		||||
                "recurring-expense-26-description_template":
 | 
			
		||||
                    "Insurance for {last_month_name}",
 | 
			
		||||
                "recurring-income-2-no": "12",
 | 
			
		||||
                "recurring-income-2-name": "Donation",
 | 
			
		||||
                "recurring-income-2-account_code": Accounts.DONATION,
 | 
			
		||||
                "recurring-income-2-description_template":
 | 
			
		||||
                    "Donation for {this_month_name}",
 | 
			
		||||
                "recurring-income-15-no": "4",
 | 
			
		||||
                "recurring-income-15-name": "Payroll",
 | 
			
		||||
                "recurring-income-15-account_code": Accounts.SERVICE,
 | 
			
		||||
                "recurring-income-15-description_template":
 | 
			
		||||
                    "Payroll for {last_month_name}"}
 | 
			
		||||
@@ -78,6 +78,10 @@ def create_app(is_testing: bool = False) -> Flask:
 | 
			
		||||
            return auth.current_user() is not None \
 | 
			
		||||
                and auth.current_user().username in ["editor", "editor2"]
 | 
			
		||||
 | 
			
		||||
        def can_admin(self) -> bool:
 | 
			
		||||
            return auth.current_user() is not None \
 | 
			
		||||
                and auth.current_user().username == "editor"
 | 
			
		||||
 | 
			
		||||
        @property
 | 
			
		||||
        def cls(self) -> t.Type[auth.User]:
 | 
			
		||||
            return auth.User
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ First written: 2023/1/27
 | 
			
		||||
  <meta name="author" content="{{ "imacat" }}" />
 | 
			
		||||
  <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" crossorigin="anonymous">
 | 
			
		||||
  <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css" crossorigin="anonymous">
 | 
			
		||||
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.2.10/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
 | 
			
		||||
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
 | 
			
		||||
  {% block styles %}{% endblock %}
 | 
			
		||||
  <script src="{{ url_for("babel_catalog") }}"></script>
 | 
			
		||||
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,33 @@ from test_site import create_app
 | 
			
		||||
 | 
			
		||||
TEST_SERVER: str = "https://testserver"
 | 
			
		||||
"""The test server URI."""
 | 
			
		||||
NEXT_URI: str = "/_next"
 | 
			
		||||
"""The next URI."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Accounts:
 | 
			
		||||
    """The shortcuts to the common accounts."""
 | 
			
		||||
    CASH: str = "1111-001"
 | 
			
		||||
    PETTY_CASH: str = "1112-001"
 | 
			
		||||
    BANK: str = "1113-001"
 | 
			
		||||
    NOTES_RECEIVABLE: str = "1131-001"
 | 
			
		||||
    RECEIVABLE: str = "1141-001"
 | 
			
		||||
    PREPAID: str = "1258-001"
 | 
			
		||||
    NOTES_PAYABLE: str = "2131-001"
 | 
			
		||||
    PAYABLE: str = "2141-001"
 | 
			
		||||
    SALES: str = "4111-001"
 | 
			
		||||
    SERVICE: str = "4611-001"
 | 
			
		||||
    AGENCY: str = "4711-001"
 | 
			
		||||
    RENT_EXPENSE: str = "6252-001"
 | 
			
		||||
    OFFICE: str = "6253-001"
 | 
			
		||||
    TRAVEL: str = "6254-001"
 | 
			
		||||
    POSTAGE: str = "6256-001"
 | 
			
		||||
    UTILITIES: str = "6261-001"
 | 
			
		||||
    INSURANCE: str = "6262-001"
 | 
			
		||||
    MEAL: str = "6272-001"
 | 
			
		||||
    INTEREST: str = "7111-001"
 | 
			
		||||
    DONATION: str = "7481-001"
 | 
			
		||||
    RENT_INCOME: str = "7482-001"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_test_app() -> Flask:
 | 
			
		||||
@@ -57,7 +84,6 @@ def get_csrf_token(client: httpx.Client) -> str:
 | 
			
		||||
    return client.get("/.csrf-token").text
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
 | 
			
		||||
    """Returns a user client.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -26,36 +26,14 @@ import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts
 | 
			
		||||
 | 
			
		||||
NEXT_URI: str = "/_next"
 | 
			
		||||
"""The next URI."""
 | 
			
		||||
NON_EMPTY_NOTE: str = "  This is \n\na test."
 | 
			
		||||
"""The stripped content of an non-empty note."""
 | 
			
		||||
EMPTY_NOTE: str = " \n\n  "
 | 
			
		||||
"""The empty note content."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Accounts:
 | 
			
		||||
    """The shortcuts to the common accounts."""
 | 
			
		||||
    CASH: str = "1111-001"
 | 
			
		||||
    PETTY_CASH: str = "1112-001"
 | 
			
		||||
    BANK: str = "1113-001"
 | 
			
		||||
    NOTES_RECEIVABLE: str = "1131-001"
 | 
			
		||||
    RECEIVABLE: str = "1141-001"
 | 
			
		||||
    PREPAID: str = "1258-001"
 | 
			
		||||
    NOTES_PAYABLE: str = "2131-001"
 | 
			
		||||
    PAYABLE: str = "2141-001"
 | 
			
		||||
    SALES: str = "4111-001"
 | 
			
		||||
    SERVICE: str = "4611-001"
 | 
			
		||||
    AGENCY: str = "4711-001"
 | 
			
		||||
    OFFICE: str = "6153-001"
 | 
			
		||||
    TRAVEL: str = "6154-001"
 | 
			
		||||
    MEAL: str = "6172-001"
 | 
			
		||||
    INTEREST: str = "7111-001"
 | 
			
		||||
    DONATION: str = "7481-001"
 | 
			
		||||
    RENT: str = "7482-001"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_add_form(csrf_token: str) -> dict[str, str]:
 | 
			
		||||
    """Returns the form data to add a new journal entry.
 | 
			
		||||
 | 
			
		||||
@@ -115,7 +93,7 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
 | 
			
		||||
            "currency-16-debit-9-description": " Gas ",
 | 
			
		||||
            "currency-16-debit-9-amount": "30000",
 | 
			
		||||
            "currency-16-credit-6-no": "6",
 | 
			
		||||
            "currency-16-credit-6-account_code": Accounts.RENT,
 | 
			
		||||
            "currency-16-credit-6-account_code": Accounts.RENT_INCOME,
 | 
			
		||||
            "currency-16-credit-6-description": " Rent ",
 | 
			
		||||
            "currency-16-credit-6-amount": "35000",
 | 
			
		||||
            "currency-16-credit-9-account_code": Accounts.DONATION,
 | 
			
		||||
@@ -349,7 +327,7 @@ def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
 | 
			
		||||
        f"{prefix}debit-14-description": "  ",
 | 
			
		||||
        f"{prefix}debit-14-amount": "14.55",
 | 
			
		||||
        f"{prefix}credit-16-no": "7",
 | 
			
		||||
        f"{prefix}credit-16-account_code": Accounts.RENT,
 | 
			
		||||
        f"{prefix}credit-16-account_code": Accounts.RENT_INCOME,
 | 
			
		||||
        f"{prefix}credit-16-description": " Bike ",
 | 
			
		||||
        f"{prefix}credit-16-amount": "19.5",
 | 
			
		||||
        f"{prefix}credit-22-no": "5",
 | 
			
		||||
 
 | 
			
		||||
@@ -26,8 +26,8 @@ import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib_journal_entry import Accounts, match_journal_entry_detail, \
 | 
			
		||||
    NEXT_URI
 | 
			
		||||
from testlib import NEXT_URI, Accounts
 | 
			
		||||
from testlib_journal_entry import match_journal_entry_detail
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalEntryLineItemData:
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user