Compare commits
	
		
			59 Commits
		
	
	
		
			v0.5.0
			...
			61fd1849ed
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 61fd1849ed | |||
| a67158f8f6 | |||
| 5c6bfd8b49 | |||
| d9ecf51c6d | |||
| 5d31eb9172 | |||
| fadce244c5 | |||
| cbe7c6ca6d | |||
| b03938fb2e | |||
| 8061a23fdc | |||
| cd8a480cd0 | |||
| b8b87714eb | |||
| bf2f96621d | |||
| 2d771f04be | |||
| 3a12472d4b | |||
| d5a686a5d8 | |||
| 690f89e29a | |||
| 82a6a53dc4 | |||
| cdd31b1047 | |||
| 5bad949cfa | |||
| 3826646d06 | |||
| 74071e8997 | |||
| 3ce34803f3 | |||
| 232f73172f | |||
| ff1bb7142b | |||
| 7155bf635a | |||
| c306ff8009 | |||
| b344abce06 | |||
| b3c666c872 | |||
| 6a671cac84 | |||
| fe87c3a7de | |||
| 2013f8cbd9 | |||
| 2325842471 | |||
| c80e58b049 | |||
| be0ae5eba4 | |||
| 2b84f64554 | |||
| 0a658a76e8 | |||
| 50dc79d865 | |||
| 8e5377a416 | |||
| 4299fd6fbd | |||
| 1d6a53f7cd | |||
| bb2993b0c0 | |||
| f6946c1165 | |||
| 8e219d8006 | |||
| 53565eb9e6 | |||
| 965e78d8ad | |||
| 74b81d3e23 | |||
| a0fba6387f | |||
| d28bdf2064 | |||
| edf0c00e34 | |||
| 107d161379 | |||
| f2c184f769 | |||
| b45986ecfc | |||
| a2c2452ec5 | |||
| 5194258b48 | |||
| 3fe7eb41ac | |||
| 7fb9e2f0a1 | |||
| 1d443f7b76 | |||
| 6ad4fba9cd | |||
| 3dda6531b5 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -38,3 +38,4 @@ excludes
 | 
			
		||||
*.mo
 | 
			
		||||
zh_Hans
 | 
			
		||||
test_temp.py
 | 
			
		||||
dummy.js
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
 | 
			
		||||
exclude src/accounting/static/js/dummy.js
 | 
			
		||||
include src/accounting/translations/*
 | 
			
		||||
include src/accounting/translations/*/LC_MESSAGES/*
 | 
			
		||||
include docs/*
 | 
			
		||||
@@ -22,6 +23,7 @@ include docs/source/*
 | 
			
		||||
include docs/source/_static/*
 | 
			
		||||
include docs/source/_templates/*
 | 
			
		||||
include tests/*
 | 
			
		||||
exclude tests/test_temp.py
 | 
			
		||||
include tests/test_site/*
 | 
			
		||||
include tests/test_site/templates/*
 | 
			
		||||
include tests/test_site/translations/*
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ from pathlib import Path
 | 
			
		||||
from flask import Flask, Blueprint
 | 
			
		||||
from flask_sqlalchemy import SQLAlchemy
 | 
			
		||||
 | 
			
		||||
from accounting.utils.user import AbstractUserUtils
 | 
			
		||||
from accounting.utils.user import UserUtilityInterface
 | 
			
		||||
 | 
			
		||||
db: SQLAlchemy = SQLAlchemy()
 | 
			
		||||
"""The database instance."""
 | 
			
		||||
@@ -31,19 +31,13 @@ data_dir: Path = Path(__file__).parent / "data"
 | 
			
		||||
"""The data directory."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
			
		||||
             url_prefix: str = "/accounting",
 | 
			
		||||
             can_view_func: t.Callable[[], bool] | None = None,
 | 
			
		||||
             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
			
		||||
def init_app(app: Flask, user_utils: UserUtilityInterface,
 | 
			
		||||
             url_prefix: str = "/accounting") -> None:
 | 
			
		||||
    """Initialize the application.
 | 
			
		||||
 | 
			
		||||
    :param app: The Flask application.
 | 
			
		||||
    :param user_utils: The user utilities.
 | 
			
		||||
    :param url_prefix: The URL prefix of the accounting application.
 | 
			
		||||
    :param can_view_func: A callback that returns whether the current user can
 | 
			
		||||
        view the accounting data.
 | 
			
		||||
    :param can_edit_func: A callback that returns whether the current user can
 | 
			
		||||
        edit the accounting data.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    # The database instance must be set before loading everything
 | 
			
		||||
@@ -73,7 +67,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
			
		||||
    locale.init_app(app, bp)
 | 
			
		||||
 | 
			
		||||
    from .utils import permission
 | 
			
		||||
    permission.init_app(bp, can_view_func, can_edit_func)
 | 
			
		||||
    permission.init_app(bp, user_utils)
 | 
			
		||||
 | 
			
		||||
    from .utils import next_uri
 | 
			
		||||
    next_uri.init_app(bp)
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,6 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
from secrets import randbelow
 | 
			
		||||
 | 
			
		||||
import click
 | 
			
		||||
@@ -93,14 +92,36 @@ def init_accounts_command(username: str) -> None:
 | 
			
		||||
    data: list[AccountData] = []
 | 
			
		||||
    for base in bases_to_add:
 | 
			
		||||
        l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
 | 
			
		||||
        is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
 | 
			
		||||
            else False
 | 
			
		||||
        is_offset_needed: bool = __is_offset_needed(base.code)
 | 
			
		||||
        data.append((get_new_id(), base.code, 1, base.title_l10n,
 | 
			
		||||
                     l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
 | 
			
		||||
    __add_accounting_accounts(data, creator_pk)
 | 
			
		||||
    click.echo(F"{len(data)} added.  Accounting accounts initialized.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __is_offset_needed(base_code: str) -> bool:
 | 
			
		||||
    """Checks that whether entries in the account need offset.
 | 
			
		||||
 | 
			
		||||
    :param base_code: The code of the base account.
 | 
			
		||||
    :return: True if entries in the account need offset, or False otherwise.
 | 
			
		||||
    """
 | 
			
		||||
    # Assets
 | 
			
		||||
    if base_code[0] == "1":
 | 
			
		||||
        if base_code[:3] in {"113", "114", "118", "184"}:
 | 
			
		||||
            return True
 | 
			
		||||
        if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
 | 
			
		||||
                         "1581", "1611", "1851", ""}:
 | 
			
		||||
            return True
 | 
			
		||||
        return False
 | 
			
		||||
    # Liabilities
 | 
			
		||||
    if base_code[0] == "2":
 | 
			
		||||
        if base_code in {"2111", "2114", "2284", "2293"}:
 | 
			
		||||
            return False
 | 
			
		||||
        return True
 | 
			
		||||
    # Only assets and liabilities need offset
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
 | 
			
		||||
        -> None:
 | 
			
		||||
    """Adds the accounts.
 | 
			
		||||
 
 | 
			
		||||
@@ -53,6 +53,21 @@ class BaseAccountAvailable:
 | 
			
		||||
                "The base account is not available."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NoOffsetNominalAccount:
 | 
			
		||||
    """The validator to check nominal account is not to be offset."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: BooleanField) -> None:
 | 
			
		||||
        if not field.data:
 | 
			
		||||
            return
 | 
			
		||||
        if not isinstance(form, AccountForm):
 | 
			
		||||
            return
 | 
			
		||||
        if form.base_code.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if form.base_code.data[0] not in {"1", "2"}:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "A nominal account does not need offset."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountForm(FlaskForm):
 | 
			
		||||
    """The form to create or edit an account."""
 | 
			
		||||
    base_code = StringField(
 | 
			
		||||
@@ -66,7 +81,8 @@ class AccountForm(FlaskForm):
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(lazy_gettext("Please fill in the title"))])
 | 
			
		||||
    """The title."""
 | 
			
		||||
    is_offset_needed = BooleanField()
 | 
			
		||||
    is_offset_needed = BooleanField(
 | 
			
		||||
        validators=[NoOffsetNominalAccount()])
 | 
			
		||||
    """Whether the the entries of this account need offset."""
 | 
			
		||||
 | 
			
		||||
    def populate_obj(self, obj: Account) -> None:
 | 
			
		||||
@@ -87,7 +103,10 @@ class AccountForm(FlaskForm):
 | 
			
		||||
            obj.base_code = self.base_code.data
 | 
			
		||||
            obj.no = count + 1
 | 
			
		||||
        obj.title = self.title.data
 | 
			
		||||
        obj.is_offset_needed = self.is_offset_needed.data
 | 
			
		||||
        if self.base_code.data[0] in {"1", "2"}:
 | 
			
		||||
            obj.is_offset_needed = self.is_offset_needed.data
 | 
			
		||||
        else:
 | 
			
		||||
            obj.is_offset_needed = False
 | 
			
		||||
        if is_new:
 | 
			
		||||
            current_user_pk: int = get_current_user_pk()
 | 
			
		||||
            obj.created_by_id = current_user_pk
 | 
			
		||||
 
 | 
			
		||||
@@ -17,8 +17,6 @@
 | 
			
		||||
"""The forms for the currency management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField, ValidationError
 | 
			
		||||
from wtforms.validators import DataRequired, Regexp, NoneOf
 | 
			
		||||
@@ -30,22 +28,25 @@ from accounting.utils.strip_text import strip_text
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CodeUnique:
 | 
			
		||||
    """The validator to check if the code is unique."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data == "":
 | 
			
		||||
            return
 | 
			
		||||
        if not isinstance(form, CurrencyForm):
 | 
			
		||||
            return
 | 
			
		||||
        if form.obj_code is not None and form.obj_code == field.data:
 | 
			
		||||
            return
 | 
			
		||||
        if db.session.get(Currency, field.data) is not None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "Code conflicts with another currency."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyForm(FlaskForm):
 | 
			
		||||
    """The form to create or edit a currency."""
 | 
			
		||||
    CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
 | 
			
		||||
    """The reserved codes that are not available."""
 | 
			
		||||
 | 
			
		||||
    class CodeUnique:
 | 
			
		||||
        """The validator to check if the code is unique."""
 | 
			
		||||
        def __call__(self, form: CurrencyForm, field: StringField) -> None:
 | 
			
		||||
            if field.data == "":
 | 
			
		||||
                return
 | 
			
		||||
            if form.obj_code is not None and form.obj_code == field.data:
 | 
			
		||||
                return
 | 
			
		||||
            if db.session.get(Currency, field.data) is not None:
 | 
			
		||||
                raise ValidationError(lazy_gettext(
 | 
			
		||||
                    "Code conflicts with another currency."))
 | 
			
		||||
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(lazy_gettext("Please fill in the code.")),
 | 
			
		||||
 
 | 
			
		||||
@@ -597,14 +597,14 @@ class JournalEntry(db.Model):
 | 
			
		||||
    """True for a debit entry, or False for a credit entry."""
 | 
			
		||||
    no = db.Column(db.Integer, nullable=False)
 | 
			
		||||
    """The entry number under the transaction and debit or credit."""
 | 
			
		||||
    offset_original_id = db.Column(db.Integer,
 | 
			
		||||
                                   db.ForeignKey(id, onupdate="CASCADE"),
 | 
			
		||||
                                   nullable=True)
 | 
			
		||||
    """The ID of the original entry to offset."""
 | 
			
		||||
    offset_original = db.relationship("JournalEntry", back_populates="offsets",
 | 
			
		||||
                                      remote_side=id, passive_deletes=True)
 | 
			
		||||
    """The original entry to offset."""
 | 
			
		||||
    offsets = db.relationship("JournalEntry", back_populates="offset_original")
 | 
			
		||||
    original_entry_id = db.Column(db.Integer,
 | 
			
		||||
                                  db.ForeignKey(id, onupdate="CASCADE"),
 | 
			
		||||
                                  nullable=True)
 | 
			
		||||
    """The ID of the original entry."""
 | 
			
		||||
    original_entry = db.relationship("JournalEntry", back_populates="offsets",
 | 
			
		||||
                                     remote_side=id, passive_deletes=True)
 | 
			
		||||
    """The original entry."""
 | 
			
		||||
    offsets = db.relationship("JournalEntry", back_populates="original_entry")
 | 
			
		||||
    """The offset entries."""
 | 
			
		||||
    currency_code = db.Column(db.String,
 | 
			
		||||
                              db.ForeignKey(Currency.code, onupdate="CASCADE"),
 | 
			
		||||
 
 | 
			
		||||
@@ -68,7 +68,10 @@ class EntryCollector:
 | 
			
		||||
            except ArithmeticError:
 | 
			
		||||
                pass
 | 
			
		||||
            conditions.append(sa.or_(*sub_conditions))
 | 
			
		||||
        return JournalEntry.query.filter(*conditions)\
 | 
			
		||||
        return JournalEntry.query.join(Transaction).filter(*conditions)\
 | 
			
		||||
            .order_by(Transaction.date,
 | 
			
		||||
                      JournalEntry.is_debit,
 | 
			
		||||
                      JournalEntry.no)\
 | 
			
		||||
            .options(selectinload(JournalEntry.account),
 | 
			
		||||
                     selectinload(JournalEntry.currency),
 | 
			
		||||
                     selectinload(JournalEntry.transaction)).all()
 | 
			
		||||
 
 | 
			
		||||
@@ -27,10 +27,15 @@ class OptionLink:
 | 
			
		||||
        """Constructs an option link.
 | 
			
		||||
 | 
			
		||||
        :param title: The title.
 | 
			
		||||
        :param url: The URI.
 | 
			
		||||
        :param url: The URL.
 | 
			
		||||
        :param is_active: True if active, or False otherwise
 | 
			
		||||
        :param fa_icon: The font-awesome icon, if any.
 | 
			
		||||
        """
 | 
			
		||||
        self.title: str = title
 | 
			
		||||
        """The title."""
 | 
			
		||||
        self.url: str = url
 | 
			
		||||
        """The URL."""
 | 
			
		||||
        self.is_active: bool = is_active
 | 
			
		||||
        """True if active, or False otherwise."""
 | 
			
		||||
        self.fa_icon: str | None = fa_icon
 | 
			
		||||
        """The font-awesome icon, if any."""
 | 
			
		||||
 
 | 
			
		||||
@@ -24,161 +24,335 @@
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    initializeBaseAccountSelector();
 | 
			
		||||
    document.getElementById("accounting-base-code")
 | 
			
		||||
        .onchange = validateBase;
 | 
			
		||||
    document.getElementById("accounting-title")
 | 
			
		||||
        .onchange = validateTitle;
 | 
			
		||||
    document.getElementById("accounting-form")
 | 
			
		||||
        .onsubmit = validateForm;
 | 
			
		||||
    AccountForm.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Initializes the base account selector.
 | 
			
		||||
 * The account form.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function initializeBaseAccountSelector() {
 | 
			
		||||
    const selector = document.getElementById("accounting-base-selector-modal");
 | 
			
		||||
    const base = document.getElementById("accounting-base");
 | 
			
		||||
    const baseCode = document.getElementById("accounting-base-code");
 | 
			
		||||
    const baseContent = document.getElementById("accounting-base-content");
 | 
			
		||||
    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
			
		||||
    const btnClear = document.getElementById("accounting-btn-clear-base");
 | 
			
		||||
    selector.addEventListener("show.bs.modal", () => {
 | 
			
		||||
        base.classList.add("accounting-not-empty");
 | 
			
		||||
        for (const option of options) {
 | 
			
		||||
            option.classList.remove("active");
 | 
			
		||||
        }
 | 
			
		||||
        const selected = document.getElementById("accounting-base-option-" + baseCode.value);
 | 
			
		||||
        if (selected !== null) {
 | 
			
		||||
            selected.classList.add("active");
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    selector.addEventListener("hidden.bs.modal", () => {
 | 
			
		||||
        if (baseCode.value === "") {
 | 
			
		||||
            base.classList.remove("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    for (const option of options) {
 | 
			
		||||
        option.onclick = () => {
 | 
			
		||||
            baseCode.value = option.dataset.code;
 | 
			
		||||
            baseContent.innerText = option.dataset.content;
 | 
			
		||||
            btnClear.classList.add("btn-danger");
 | 
			
		||||
            btnClear.classList.remove("btn-secondary")
 | 
			
		||||
            btnClear.disabled = false;
 | 
			
		||||
            validateBase();
 | 
			
		||||
            bootstrap.Modal.getInstance(selector).hide();
 | 
			
		||||
class AccountForm {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The base account selector
 | 
			
		||||
     * @type {BaseAccountSelector}
 | 
			
		||||
     */
 | 
			
		||||
    #baseAccountSelector;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The form element
 | 
			
		||||
     * @type {HTMLFormElement}
 | 
			
		||||
     */
 | 
			
		||||
    #formElement;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control of the base account
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #baseControl;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The input of the base account
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #baseCode;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The base account
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #base;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message for the base account
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #baseError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The title
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #title;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message of the title
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #titleError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control of the is-offset-needed option
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #isOffsetNeededControl;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The is-offset-needed option
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #isOffsetNeeded;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs the account form.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.#baseAccountSelector = new BaseAccountSelector(this);
 | 
			
		||||
        this.#formElement = document.getElementById("accounting-form");
 | 
			
		||||
        this.#baseControl = document.getElementById("accounting-base-control");
 | 
			
		||||
        this.#baseCode = document.getElementById("accounting-base-code");
 | 
			
		||||
        this.#base = document.getElementById("accounting-base");
 | 
			
		||||
        this.#baseError = document.getElementById("accounting-base-error");
 | 
			
		||||
        this.#title = document.getElementById("accounting-title");
 | 
			
		||||
        this.#titleError = document.getElementById("accounting-title-error");
 | 
			
		||||
        this.#isOffsetNeededControl = document.getElementById("accounting-is-offset-needed-control");
 | 
			
		||||
        this.#isOffsetNeeded = document.getElementById("accounting-is-offset-needed");
 | 
			
		||||
        this.#formElement.onsubmit = () => {
 | 
			
		||||
            return this.#validateForm();
 | 
			
		||||
        };
 | 
			
		||||
        this.#baseControl.onclick = () => {
 | 
			
		||||
            this.#baseControl.classList.add("accounting-not-empty");
 | 
			
		||||
            this.#baseAccountSelector.onOpen(this.#baseCode.value);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
    btnClear.onclick = () => {
 | 
			
		||||
        baseCode.value = "";
 | 
			
		||||
        baseContent.innerText = "";
 | 
			
		||||
        btnClear.classList.add("btn-secondary")
 | 
			
		||||
        btnClear.classList.remove("btn-danger");
 | 
			
		||||
        btnClear.disabled = true;
 | 
			
		||||
        validateBase();
 | 
			
		||||
        bootstrap.Modal.getInstance(selector).hide();
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the base account selector is closed.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    onBaseAccountSelectorClosed() {
 | 
			
		||||
        if (this.#baseCode.value === "") {
 | 
			
		||||
            this.#baseControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets the base account.
 | 
			
		||||
     *
 | 
			
		||||
     * @param code {string} the base account code
 | 
			
		||||
     * @param text {string} the text for the base account
 | 
			
		||||
     */
 | 
			
		||||
    setBaseAccount(code, text) {
 | 
			
		||||
        this.#baseCode.value = code;
 | 
			
		||||
        this.#base.innerText = text;
 | 
			
		||||
        if (["1", "2"].includes(code.substring(0, 1))) {
 | 
			
		||||
            this.#isOffsetNeededControl.classList.remove("d-none");
 | 
			
		||||
            this.#isOffsetNeeded.disabled = false;
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#isOffsetNeededControl.classList.add("d-none");
 | 
			
		||||
            this.#isOffsetNeeded.disabled = true;
 | 
			
		||||
            this.#isOffsetNeeded.checked = false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#validateBase();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clears the base account.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    clearBaseAccount() {
 | 
			
		||||
        this.#baseCode.value = "";
 | 
			
		||||
        this.#base.innerText = "";
 | 
			
		||||
        this.#validateBase();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateForm() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = this.#validateBase() && isValid;
 | 
			
		||||
        isValid = this.#validateTitle() && isValid;
 | 
			
		||||
        return isValid;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the base account.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateBase() {
 | 
			
		||||
        if (this.#baseCode.value === "") {
 | 
			
		||||
            this.#baseControl.classList.add("is-invalid");
 | 
			
		||||
            this.#baseError.innerText = A_("Please select the base account.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#baseControl.classList.remove("is-invalid");
 | 
			
		||||
        this.#baseError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the title.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateTitle() {
 | 
			
		||||
        this.#title.value = this.#title.value.trim();
 | 
			
		||||
        if (this.#title.value === "") {
 | 
			
		||||
            this.#title.classList.add("is-invalid");
 | 
			
		||||
            this.#titleError.innerText = A_("Please fill in the title.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#title.classList.remove("is-invalid");
 | 
			
		||||
        this.#titleError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account form
 | 
			
		||||
     * @type {AccountForm} the form
 | 
			
		||||
     */
 | 
			
		||||
    static #form;
 | 
			
		||||
 | 
			
		||||
    static initialize() {
 | 
			
		||||
        this.#form = new AccountForm();
 | 
			
		||||
    }
 | 
			
		||||
    initializeBaseAccountQuery();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Initializes the query on the base account options.
 | 
			
		||||
 * The base account selector.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function initializeBaseAccountQuery() {
 | 
			
		||||
    const query = document.getElementById("accounting-base-selector-query");
 | 
			
		||||
    const optionList = document.getElementById("accounting-base-option-list");
 | 
			
		||||
    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
			
		||||
    const queryNoResult = document.getElementById("accounting-base-option-no-result");
 | 
			
		||||
    query.addEventListener("input", () => {
 | 
			
		||||
        if (query.value === "") {
 | 
			
		||||
            for (const option of options) {
 | 
			
		||||
                option.classList.remove("d-none");
 | 
			
		||||
            }
 | 
			
		||||
            optionList.classList.remove("d-none");
 | 
			
		||||
            queryNoResult.classList.add("d-none");
 | 
			
		||||
            return
 | 
			
		||||
class BaseAccountSelector {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account form
 | 
			
		||||
     * @type {AccountForm}
 | 
			
		||||
     */
 | 
			
		||||
    #form;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The selector modal
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #modal;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The query input
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #query;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message when the query has no result
 | 
			
		||||
     * @type {HTMLParagraphElement}
 | 
			
		||||
     */
 | 
			
		||||
    #queryNoResult;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The option list
 | 
			
		||||
     * @type {HTMLUListElement}
 | 
			
		||||
     */
 | 
			
		||||
    #optionList;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The options
 | 
			
		||||
     * @type {HTMLLIElement[]}
 | 
			
		||||
     */
 | 
			
		||||
    #options;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The button to clear the base account value
 | 
			
		||||
     * @type {HTMLButtonElement}
 | 
			
		||||
     */
 | 
			
		||||
    #clearButton;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs the base account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param form {AccountForm} the form
 | 
			
		||||
     */
 | 
			
		||||
    constructor(form) {
 | 
			
		||||
        this.#form = form;
 | 
			
		||||
        this.#modal = document.getElementById("accounting-base-selector-modal");
 | 
			
		||||
        this.#query = document.getElementById("accounting-base-selector-query");
 | 
			
		||||
        this.#optionList = document.getElementById("accounting-base-selector-option-list");
 | 
			
		||||
        // noinspection JSValidateTypes
 | 
			
		||||
        this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
 | 
			
		||||
        this.#clearButton = document.getElementById("accounting-base-selector-clear");
 | 
			
		||||
        this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
 | 
			
		||||
        this.#modal.addEventListener("hidden.bs.modal", () => {
 | 
			
		||||
            this.#form.onBaseAccountSelectorClosed();
 | 
			
		||||
        });
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            option.onclick = () => {
 | 
			
		||||
                this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        let hasAnyMatched = false;
 | 
			
		||||
        for (const option of options) {
 | 
			
		||||
            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
			
		||||
            let isMatched = false;
 | 
			
		||||
            for (const queryValue of queryValues) {
 | 
			
		||||
                if (queryValue.includes(query.value)) {
 | 
			
		||||
                    isMatched = true;
 | 
			
		||||
                    break;
 | 
			
		||||
        this.#clearButton.onclick = () => {
 | 
			
		||||
            this.#form.clearBaseAccount();
 | 
			
		||||
        };
 | 
			
		||||
        this.#initializeBaseAccountQuery();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the query.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeBaseAccountQuery() {
 | 
			
		||||
        this.#query.addEventListener("input", () => {
 | 
			
		||||
            if (this.#query.value === "") {
 | 
			
		||||
                for (const option of this.#options) {
 | 
			
		||||
                    option.classList.remove("d-none");
 | 
			
		||||
                }
 | 
			
		||||
                this.#optionList.classList.remove("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.add("d-none");
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
            let hasAnyMatched = false;
 | 
			
		||||
            for (const option of this.#options) {
 | 
			
		||||
                const queryValues = JSON.parse(option.dataset.queryValues);
 | 
			
		||||
                let isMatched = false;
 | 
			
		||||
                for (const queryValue of queryValues) {
 | 
			
		||||
                    if (queryValue.includes(this.#query.value)) {
 | 
			
		||||
                        isMatched = true;
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if (isMatched) {
 | 
			
		||||
                    option.classList.remove("d-none");
 | 
			
		||||
                    hasAnyMatched = true;
 | 
			
		||||
                } else {
 | 
			
		||||
                    option.classList.add("d-none");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (isMatched) {
 | 
			
		||||
                option.classList.remove("d-none");
 | 
			
		||||
                hasAnyMatched = true;
 | 
			
		||||
            if (!hasAnyMatched) {
 | 
			
		||||
                this.#optionList.classList.add("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.remove("d-none");
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.add("d-none");
 | 
			
		||||
                this.#optionList.classList.remove("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.add("d-none");
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the base account selector is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param baseCode {string} the active base code
 | 
			
		||||
     */
 | 
			
		||||
    onOpen(baseCode) {
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.dataset.code === baseCode) {
 | 
			
		||||
                option.classList.add("active");
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.remove("active");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!hasAnyMatched) {
 | 
			
		||||
            optionList.classList.add("d-none");
 | 
			
		||||
            queryNoResult.classList.remove("d-none");
 | 
			
		||||
        if (baseCode === "") {
 | 
			
		||||
            this.#clearButton.classList.add("btn-secondary")
 | 
			
		||||
            this.#clearButton.classList.remove("btn-danger");
 | 
			
		||||
            this.#clearButton.disabled = true;
 | 
			
		||||
        } else {
 | 
			
		||||
            optionList.classList.remove("d-none");
 | 
			
		||||
            queryNoResult.classList.add("d-none");
 | 
			
		||||
            this.#clearButton.classList.add("btn-danger");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-secondary")
 | 
			
		||||
            this.#clearButton.disabled = false;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the form.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateForm() {
 | 
			
		||||
    let isValid = true;
 | 
			
		||||
    isValid = validateBase() && isValid;
 | 
			
		||||
    isValid = validateTitle() && isValid;
 | 
			
		||||
    return isValid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the base account.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateBase() {
 | 
			
		||||
    const field = document.getElementById("accounting-base-code");
 | 
			
		||||
    const error = document.getElementById("accounting-base-code-error");
 | 
			
		||||
    const displayField = document.getElementById("accounting-base");
 | 
			
		||||
    field.value = field.value.trim();
 | 
			
		||||
    if (field.value === "") {
 | 
			
		||||
        displayField.classList.add("is-invalid");
 | 
			
		||||
        error.innerText = A_("Please select the base account.");
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
    displayField.classList.remove("is-invalid");
 | 
			
		||||
    error.innerText = "";
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the title.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateTitle() {
 | 
			
		||||
    const field = document.getElementById("accounting-title");
 | 
			
		||||
    const error = document.getElementById("accounting-title-error");
 | 
			
		||||
    field.value = field.value.trim();
 | 
			
		||||
    if (field.value === "") {
 | 
			
		||||
        field.classList.add("is-invalid");
 | 
			
		||||
        error.innerText = A_("Please fill in the title.");
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
    field.classList.remove("is-invalid");
 | 
			
		||||
    error.innerText = "";
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -45,78 +45,89 @@ class AccountSelector {
 | 
			
		||||
     */
 | 
			
		||||
    #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;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The journal entry editor
 | 
			
		||||
     * @type {JournalEntryEditor}
 | 
			
		||||
     */
 | 
			
		||||
    #entryEditor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs an account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param modal {HTMLFormElement} the account selector modal
 | 
			
		||||
     * @param modal {HTMLDivElement} the account selector modal
 | 
			
		||||
     */
 | 
			
		||||
    constructor(modal) {
 | 
			
		||||
        this.#entryType = modal.dataset.entryType;
 | 
			
		||||
        this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
 | 
			
		||||
        this.#init();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the account selector.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #init() {
 | 
			
		||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
			
		||||
        const more = document.getElementById(this.#prefix + "-more");
 | 
			
		||||
        const btnClear = document.getElementById(this.#prefix + "-btn-clear");
 | 
			
		||||
        const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
			
		||||
        more.onclick = () => {
 | 
			
		||||
            more.classList.add("d-none");
 | 
			
		||||
            this.#filterAccountOptions();
 | 
			
		||||
        this.#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.#initializeAccountQuery();
 | 
			
		||||
        btnClear.onclick = () => {
 | 
			
		||||
            formAccountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
            formAccount.innerText = "";
 | 
			
		||||
            formAccount.dataset.code = "";
 | 
			
		||||
            formAccount.dataset.text = "";
 | 
			
		||||
            validateJournalEntryAccount();
 | 
			
		||||
        this.#clearButton.onclick = () => {
 | 
			
		||||
            this.#entryEditor.clearAccount();
 | 
			
		||||
        };
 | 
			
		||||
        for (const option of options) {
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            option.onclick = () => {
 | 
			
		||||
                formAccountControl.classList.add("accounting-not-empty");
 | 
			
		||||
                formAccount.innerText = option.dataset.content;
 | 
			
		||||
                formAccount.dataset.code = option.dataset.code;
 | 
			
		||||
                formAccount.dataset.text = option.dataset.content;
 | 
			
		||||
                validateJournalEntryAccount();
 | 
			
		||||
                this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the query on the account options.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeAccountQuery() {
 | 
			
		||||
        const query = document.getElementById(this.#prefix + "-query");
 | 
			
		||||
        query.addEventListener("input", () => {
 | 
			
		||||
            this.#filterAccountOptions();
 | 
			
		||||
        this.#query.addEventListener("input", () => {
 | 
			
		||||
            this.#filterOptions();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filters the account options.
 | 
			
		||||
     * Filters the options.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #filterAccountOptions() {
 | 
			
		||||
        const query = document.getElementById(this.#prefix + "-query");
 | 
			
		||||
        const optionList = document.getElementById(this.#prefix + "-option-list");
 | 
			
		||||
        if (optionList === null) {
 | 
			
		||||
            console.log(this.#prefix + "-option-list");
 | 
			
		||||
        }
 | 
			
		||||
        const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
			
		||||
        const more = document.getElementById(this.#prefix + "-more");
 | 
			
		||||
        const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
 | 
			
		||||
        const codesInUse = this.#getAccountCodeUsedInForm();
 | 
			
		||||
    #filterOptions() {
 | 
			
		||||
        const codesInUse = this.#getCodesUsedInForm();
 | 
			
		||||
        let shouldAnyShow = false;
 | 
			
		||||
        for (const option of options) {
 | 
			
		||||
            const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
 | 
			
		||||
        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;
 | 
			
		||||
@@ -124,12 +135,12 @@ class AccountSelector {
 | 
			
		||||
                option.classList.add("d-none");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!shouldAnyShow && more.classList.contains("d-none")) {
 | 
			
		||||
            optionList.classList.add("d-none");
 | 
			
		||||
            queryNoResult.classList.remove("d-none");
 | 
			
		||||
        if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
 | 
			
		||||
            this.#optionList.classList.add("d-none");
 | 
			
		||||
            this.#queryNoResult.classList.remove("d-none");
 | 
			
		||||
        } else {
 | 
			
		||||
            optionList.classList.remove("d-none");
 | 
			
		||||
            queryNoResult.classList.add("d-none");
 | 
			
		||||
            this.#optionList.classList.remove("d-none");
 | 
			
		||||
            this.#queryNoResult.classList.add("d-none");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -138,26 +149,24 @@ class AccountSelector {
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string[]} the account codes that are used in the form
 | 
			
		||||
     */
 | 
			
		||||
    #getAccountCodeUsedInForm() {
 | 
			
		||||
        const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
 | 
			
		||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
			
		||||
        const inUse = [formAccount.dataset.code];
 | 
			
		||||
        for (const accountCode of accountCodes) {
 | 
			
		||||
            inUse.push(accountCode.value);
 | 
			
		||||
    #getCodesUsedInForm() {
 | 
			
		||||
        const inUse = this.#entryEditor.getTransactionForm().getAccountCodesUsed(this.#entryType);
 | 
			
		||||
        if (this.#entryEditor.getAccountCode() !== null) {
 | 
			
		||||
            inUse.push(this.#entryEditor.getAccountCode());
 | 
			
		||||
        }
 | 
			
		||||
        return inUse
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether an account option should show.
 | 
			
		||||
     * Returns whether an option should show.
 | 
			
		||||
     *
 | 
			
		||||
     * @param option {HTMLLIElement} the account option
 | 
			
		||||
     * @param more {HTMLLIElement} the more account element
 | 
			
		||||
     * @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 account option should show, or false otherwise
 | 
			
		||||
     * @return {boolean} true if the option should show, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #shouldAccountOptionShow(option, more, inUse, query) {
 | 
			
		||||
    #shouldOptionShow(option, more, inUse, query) {
 | 
			
		||||
        const isQueryMatched = () => {
 | 
			
		||||
            if (query.value === "") {
 | 
			
		||||
                return true;
 | 
			
		||||
@@ -180,33 +189,30 @@ class AccountSelector {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the account selector when it is shown.
 | 
			
		||||
     * The callback when the account selector is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entryEditor {JournalEntryEditor} the journal entry editor
 | 
			
		||||
     */
 | 
			
		||||
    initShow() {
 | 
			
		||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
			
		||||
        const query = document.getElementById(this.#prefix + "-query")
 | 
			
		||||
        const more = document.getElementById(this.#prefix + "-more");
 | 
			
		||||
        const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
			
		||||
        const btnClear = document.getElementById(this.#prefix + "-btn-clear");
 | 
			
		||||
        query.value = "";
 | 
			
		||||
        more.classList.remove("d-none");
 | 
			
		||||
        this.#filterAccountOptions();
 | 
			
		||||
        for (const option of options) {
 | 
			
		||||
            if (option.dataset.code === formAccount.dataset.code) {
 | 
			
		||||
    #onOpen(entryEditor) {
 | 
			
		||||
        this.#entryEditor = entryEditor;
 | 
			
		||||
        this.#query.value = "";
 | 
			
		||||
        this.#more.classList.remove("d-none");
 | 
			
		||||
        this.#filterOptions();
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.dataset.code === entryEditor.getAccountCode()) {
 | 
			
		||||
                option.classList.add("active");
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.remove("active");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (formAccount.dataset.code === "") {
 | 
			
		||||
            btnClear.classList.add("btn-secondary");
 | 
			
		||||
            btnClear.classList.remove("btn-danger");
 | 
			
		||||
            btnClear.disabled = true;
 | 
			
		||||
        if (entryEditor.getAccountCode() === null) {
 | 
			
		||||
            this.#clearButton.classList.add("btn-secondary");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-danger");
 | 
			
		||||
            this.#clearButton.disabled = true;
 | 
			
		||||
        } else {
 | 
			
		||||
            btnClear.classList.add("btn-danger");
 | 
			
		||||
            btnClear.classList.remove("btn-secondary");
 | 
			
		||||
            btnClear.disabled = false;
 | 
			
		||||
            this.#clearButton.classList.add("btn-danger");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-secondary");
 | 
			
		||||
            this.#clearButton.disabled = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -226,25 +232,15 @@ class AccountSelector {
 | 
			
		||||
            const selector = new AccountSelector(modal);
 | 
			
		||||
            this.#selectors[selector.#entryType] = selector;
 | 
			
		||||
        }
 | 
			
		||||
        this.#initializeTransactionForm();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the transaction form.
 | 
			
		||||
     * Starts the account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entryEditor {JournalEntryEditor} the journal entry editor
 | 
			
		||||
     * @param entryType {string} the entry type, either "debit" or "credit"
 | 
			
		||||
     */
 | 
			
		||||
    static #initializeTransactionForm() {
 | 
			
		||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
			
		||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the account selector for the journal entry form.
 | 
			
		||||
     *x
 | 
			
		||||
     */
 | 
			
		||||
    static initializeJournalEntryForm() {
 | 
			
		||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
			
		||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
 | 
			
		||||
    static start(entryEditor, entryType) {
 | 
			
		||||
        this.#selectors[entryType].#onOpen(entryEditor);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -24,152 +24,151 @@
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    document.getElementById("accounting-code")
 | 
			
		||||
        .onchange = validateCode;
 | 
			
		||||
    document.getElementById("accounting-name")
 | 
			
		||||
        .onchange = validateName;
 | 
			
		||||
    document.getElementById("accounting-form")
 | 
			
		||||
        .onsubmit = validateForm;
 | 
			
		||||
    CurrencyForm.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The asynchronous validation result
 | 
			
		||||
 * @type {object}
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
let isAsyncValid = {};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the form.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateForm() {
 | 
			
		||||
    isAsyncValid = {
 | 
			
		||||
        "code": false,
 | 
			
		||||
        "_sync": false,
 | 
			
		||||
    };
 | 
			
		||||
    let isValid = true;
 | 
			
		||||
    isValid = validateCode() && isValid;
 | 
			
		||||
    isValid = validateName() && isValid;
 | 
			
		||||
    isAsyncValid["_sync"] = isValid;
 | 
			
		||||
    submitFormIfAllAsyncValid();
 | 
			
		||||
    return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Submits the form if the whole form passed the asynchronous
 | 
			
		||||
 * validations.
 | 
			
		||||
 * The currency form.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function submitFormIfAllAsyncValid() {
 | 
			
		||||
    let isValid = true;
 | 
			
		||||
    for (const key of Object.keys(isAsyncValid)) {
 | 
			
		||||
        isValid = isAsyncValid[key] && isValid;
 | 
			
		||||
    }
 | 
			
		||||
    if (isValid) {
 | 
			
		||||
        document.getElementById("accounting-form").submit()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
class CurrencyForm {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the code.
 | 
			
		||||
 *
 | 
			
		||||
 * @param changeEvent {Event} the change event, if invoked from onchange
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateCode(changeEvent = null) {
 | 
			
		||||
    const key = "code";
 | 
			
		||||
    const isSubmission = changeEvent === null;
 | 
			
		||||
    let hasAsyncValidation = false;
 | 
			
		||||
    const field = document.getElementById("accounting-code");
 | 
			
		||||
    const error = document.getElementById("accounting-code-error");
 | 
			
		||||
    field.value = field.value.trim();
 | 
			
		||||
    if (field.value === "") {
 | 
			
		||||
        field.classList.add("is-invalid");
 | 
			
		||||
        error.innerText = A_("Please fill in the code.");
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
    const blocklist = JSON.parse(field.dataset.blocklist);
 | 
			
		||||
    if (blocklist.includes(field.value)) {
 | 
			
		||||
        field.classList.add("is-invalid");
 | 
			
		||||
        error.innerText = A_("This code is not available.");
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
    if (!field.value.match(/^[A-Z]{3}$/)) {
 | 
			
		||||
        field.classList.add("is-invalid");
 | 
			
		||||
        error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
    const original = field.dataset.original;
 | 
			
		||||
    if (original === "" || field.value !== original) {
 | 
			
		||||
        hasAsyncValidation = true;
 | 
			
		||||
        validateAsyncCodeIsDuplicated(isSubmission, key);
 | 
			
		||||
    }
 | 
			
		||||
    if (!hasAsyncValidation) {
 | 
			
		||||
        isAsyncValid[key] = true;
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
    }
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
    /**
 | 
			
		||||
     * The form.
 | 
			
		||||
     * @type {HTMLFormElement}
 | 
			
		||||
     */
 | 
			
		||||
    #formElement;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates asynchronously whether the code is duplicated.
 | 
			
		||||
 * The boolean validation result is stored in isAsyncValid[key].
 | 
			
		||||
 *
 | 
			
		||||
 * @param isSubmission {boolean} whether this is invoked from a form submission
 | 
			
		||||
 * @param key {string} the key to store the result in isAsyncValid
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateAsyncCodeIsDuplicated(isSubmission, key) {
 | 
			
		||||
    const field = document.getElementById("accounting-code");
 | 
			
		||||
    const error = document.getElementById("accounting-code-error");
 | 
			
		||||
    const url = field.dataset.existsUrl;
 | 
			
		||||
    const onLoad = function () {
 | 
			
		||||
        if (this.status === 200) {
 | 
			
		||||
            const result = JSON.parse(this.responseText);
 | 
			
		||||
            if (result["exists"]) {
 | 
			
		||||
                field.classList.add("is-invalid");
 | 
			
		||||
                error.innerText = A_("Code conflicts with another currency.");
 | 
			
		||||
                if (isSubmission) {
 | 
			
		||||
                    isAsyncValid[key] = false;
 | 
			
		||||
    /**
 | 
			
		||||
     * The code
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #code;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message of the code
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #codeError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The name
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #name;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message of the name
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #nameError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs the currency form.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.#formElement = document.getElementById("accounting-form");
 | 
			
		||||
        this.#code = document.getElementById("accounting-code");
 | 
			
		||||
        this.#codeError = document.getElementById("accounting-code-error");
 | 
			
		||||
        this.#name = document.getElementById("accounting-name");
 | 
			
		||||
        this.#nameError = document.getElementById("accounting-name-error");
 | 
			
		||||
        this.#code.onchange = () => {
 | 
			
		||||
            this.#validateCode().then();
 | 
			
		||||
        };
 | 
			
		||||
        this.#name.onchange = () => {
 | 
			
		||||
            this.#validateName();
 | 
			
		||||
        };
 | 
			
		||||
        this.#formElement.onsubmit = () => {
 | 
			
		||||
            this.#validateForm().then((isValid) => {
 | 
			
		||||
                if (isValid) {
 | 
			
		||||
                    this.#formElement.submit();
 | 
			
		||||
                }
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            field.classList.remove("is-invalid");
 | 
			
		||||
            error.innerText = "";
 | 
			
		||||
            if (isSubmission) {
 | 
			
		||||
                isAsyncValid[key] = true;
 | 
			
		||||
                submitFormIfAllAsyncValid();
 | 
			
		||||
            });
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {Promise<boolean>} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    async #validateForm() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = await this.#validateCode() && isValid;
 | 
			
		||||
        isValid = this.#validateName() && isValid;
 | 
			
		||||
        return isValid;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the code.
 | 
			
		||||
     *
 | 
			
		||||
     * @param changeEvent {Event} the change event, if invoked from onchange
 | 
			
		||||
     * @returns {Promise<boolean>} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    async #validateCode(changeEvent = null) {
 | 
			
		||||
        this.#code.value = this.#code.value.trim();
 | 
			
		||||
        if (this.#code.value === "") {
 | 
			
		||||
            this.#code.classList.add("is-invalid");
 | 
			
		||||
            this.#codeError.innerText = A_("Please fill in the code.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        const blocklist = JSON.parse(this.#code.dataset.blocklist);
 | 
			
		||||
        if (blocklist.includes(this.#code.value)) {
 | 
			
		||||
            this.#code.classList.add("is-invalid");
 | 
			
		||||
            this.#codeError.innerText = A_("This code is not available.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (!this.#code.value.match(/^[A-Z]{3}$/)) {
 | 
			
		||||
            this.#code.classList.add("is-invalid");
 | 
			
		||||
            this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        const original = this.#code.dataset.original;
 | 
			
		||||
        if (original === "" || this.#code.value !== original) {
 | 
			
		||||
            const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
 | 
			
		||||
            const data = await response.json();
 | 
			
		||||
            if (data["exists"]) {
 | 
			
		||||
                this.#code.classList.add("is-invalid");
 | 
			
		||||
                this.#codeError.innerText = A_("Code conflicts with another currency.");
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
    const request = new XMLHttpRequest();
 | 
			
		||||
    request.onload = onLoad;
 | 
			
		||||
    request.open("GET", url + "?q=" + encodeURIComponent(field.value));
 | 
			
		||||
    request.send();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the name.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateName() {
 | 
			
		||||
    const field = document.getElementById("accounting-name");
 | 
			
		||||
    const error = document.getElementById("accounting-name-error");
 | 
			
		||||
    field.value = field.value.trim();
 | 
			
		||||
    if (field.value === "") {
 | 
			
		||||
        field.classList.add("is-invalid");
 | 
			
		||||
        error.innerText = A_("Please fill in the name.");
 | 
			
		||||
        return false;
 | 
			
		||||
        this.#code.classList.remove("is-invalid");
 | 
			
		||||
        this.#codeError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the name.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateName() {
 | 
			
		||||
        this.#name.value = this.#name.value.trim();
 | 
			
		||||
        if (this.#name.value === "") {
 | 
			
		||||
            this.#name.classList.add("is-invalid");
 | 
			
		||||
            this.#nameError.innerText = A_("Please fill in the name.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#name.classList.remove("is-invalid");
 | 
			
		||||
        this.#nameError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The form
 | 
			
		||||
     * @type {CurrencyForm}
 | 
			
		||||
     */
 | 
			
		||||
    static #form;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the currency form.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    static initialize() {
 | 
			
		||||
        this.#form = new CurrencyForm();
 | 
			
		||||
    }
 | 
			
		||||
    field.classList.remove("is-invalid");
 | 
			
		||||
    error.innerText = "";
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										386
									
								
								src/accounting/static/js/journal-entry-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										386
									
								
								src/accounting/static/js/journal-entry-editor.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,386 @@
 | 
			
		||||
/* The Mia! Accounting Flask Project
 | 
			
		||||
 * journal-entry-editor.js: The JavaScript for the journal entry editor
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*  Copyright (c) 2023 imacat.
 | 
			
		||||
 *
 | 
			
		||||
 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 *  you may not use this file except in compliance with the License.
 | 
			
		||||
 *  You may obtain a copy of the License at
 | 
			
		||||
 *
 | 
			
		||||
 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *
 | 
			
		||||
 *  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 *  See the License for the specific language governing permissions and
 | 
			
		||||
 *  limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/25
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    JournalEntryEditor.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The journal entry editor.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class JournalEntryEditor {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The journal entry editor
 | 
			
		||||
     * @type {HTMLFormElement}
 | 
			
		||||
     */
 | 
			
		||||
    #element;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The bootstrap modal
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #modal;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The entry type, either "debit" or "credit"
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    entryType;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #prefix = "accounting-entry-editor"
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control of the summary
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #summaryControl;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The summary
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #summary;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message of the summary
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #summaryError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control of the account
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #accountControl;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #account;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message of the account
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #accountError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The amount
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #amount;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error message of the amount
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #amountError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The journal entry to edit
 | 
			
		||||
     * @type {JournalEntrySubForm|null}
 | 
			
		||||
     */
 | 
			
		||||
    #entry;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The debit or credit entry side sub-form
 | 
			
		||||
     * @type {DebitCreditSideSubForm}
 | 
			
		||||
     */
 | 
			
		||||
    #side;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a new journal entry editor.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.#element = document.getElementById(this.#prefix);
 | 
			
		||||
        this.#modal = document.getElementById(this.#prefix + "-modal");
 | 
			
		||||
        this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
 | 
			
		||||
        this.#summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
 | 
			
		||||
        this.#accountControl = document.getElementById(this.#prefix + "-account-control");
 | 
			
		||||
        this.#account = document.getElementById(this.#prefix + "-account");
 | 
			
		||||
        this.#accountError = document.getElementById(this.#prefix + "-account-error")
 | 
			
		||||
        this.#amount = document.getElementById(this.#prefix + "-amount");
 | 
			
		||||
        this.#amountError = document.getElementById(this.#prefix + "-amount-error")
 | 
			
		||||
        this.#summaryControl.onclick = () => {
 | 
			
		||||
            SummaryEditor.start(this, this.#summary.dataset.value);
 | 
			
		||||
        };
 | 
			
		||||
        this.#accountControl.onclick = () => {
 | 
			
		||||
            AccountSelector.start(this, this.entryType);
 | 
			
		||||
        }
 | 
			
		||||
        this.#element.onsubmit = () => {
 | 
			
		||||
            if (this.#validate()) {
 | 
			
		||||
                if (this.#entry === null) {
 | 
			
		||||
                    this.#entry = this.#side.addJournalEntry();
 | 
			
		||||
                }
 | 
			
		||||
                this.#entry.save(this.#account.dataset.code, this.#account.dataset.text, this.#summary.dataset.value, this.#amount.value);
 | 
			
		||||
                bootstrap.Modal.getInstance(this.#modal).hide();
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the transaction form.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {TransactionForm} the transaction form
 | 
			
		||||
     */
 | 
			
		||||
    getTransactionForm() {
 | 
			
		||||
        return this.#side.currency.form;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Saves the summary from the summary editor.
 | 
			
		||||
     *
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     */
 | 
			
		||||
    saveSummary(summary) {
 | 
			
		||||
        if (summary === "") {
 | 
			
		||||
            this.#summaryControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#summaryControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.#summary.dataset.value = summary;
 | 
			
		||||
        this.#summary.innerText = summary;
 | 
			
		||||
        bootstrap.Modal.getOrCreateInstance(this.#modal).show();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Saves the summary with the suggested account from the summary editor.
 | 
			
		||||
     *
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     * @param accountCode {string} the account code
 | 
			
		||||
     * @param accountText {string} the account text
 | 
			
		||||
     */
 | 
			
		||||
    saveSummaryWithAccount(summary, accountCode, accountText) {
 | 
			
		||||
        this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        this.#account.dataset.code = accountCode;
 | 
			
		||||
        this.#account.dataset.text = accountText;
 | 
			
		||||
        this.#account.innerText = accountText;
 | 
			
		||||
        this.#validateAccount();
 | 
			
		||||
        this.saveSummary(summary)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the account code.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the account code
 | 
			
		||||
     */
 | 
			
		||||
    getAccountCode() {
 | 
			
		||||
        return this.#account.dataset.code === "" ? null : this.#account.dataset.code;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clears the account.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    clearAccount() {
 | 
			
		||||
        this.#accountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        this.#account.dataset.code = "";
 | 
			
		||||
        this.#account.dataset.text = "";
 | 
			
		||||
        this.#account.innerText = "";
 | 
			
		||||
        this.#validateAccount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets the account.
 | 
			
		||||
     *
 | 
			
		||||
     * @param code {string} the account code
 | 
			
		||||
     * @param text {string} the account text
 | 
			
		||||
     */
 | 
			
		||||
    saveAccount(code, text) {
 | 
			
		||||
        this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        this.#account.dataset.code = code;
 | 
			
		||||
        this.#account.dataset.text = text;
 | 
			
		||||
        this.#account.innerText = text;
 | 
			
		||||
        this.#validateAccount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validate() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = this.#validateSummary() && isValid;
 | 
			
		||||
        isValid = this.#validateAccount() && isValid;
 | 
			
		||||
        isValid = this.#validateAmount() && isValid
 | 
			
		||||
        return isValid;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the summary.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     * @private
 | 
			
		||||
     */
 | 
			
		||||
    #validateSummary() {
 | 
			
		||||
        this.#summary.classList.remove("is-invalid");
 | 
			
		||||
        this.#summaryError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the account.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateAccount() {
 | 
			
		||||
        if (this.#account.dataset.code === "") {
 | 
			
		||||
            this.#accountControl.classList.add("is-invalid");
 | 
			
		||||
            this.#accountError.innerText = A_("Please select the account.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#accountControl.classList.remove("is-invalid");
 | 
			
		||||
        this.#accountError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the amount.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     * @private
 | 
			
		||||
     */
 | 
			
		||||
    #validateAmount() {
 | 
			
		||||
        this.#amount.value = this.#amount.value.trim();
 | 
			
		||||
        this.#amount.classList.remove("is-invalid");
 | 
			
		||||
        if (this.#amount.value === "") {
 | 
			
		||||
            this.#amount.classList.add("is-invalid");
 | 
			
		||||
            this.#amountError.innerText = A_("Please fill in the amount.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#amount.classList.remove("is-invalid");
 | 
			
		||||
        this.#amount.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds a new journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param side {DebitCreditSideSubForm} the debit or credit side sub-form
 | 
			
		||||
     */
 | 
			
		||||
    #onAddNew(side) {
 | 
			
		||||
        this.#entry = null;
 | 
			
		||||
        this.#side = side;
 | 
			
		||||
        this.entryType = this.#side.entryType;
 | 
			
		||||
        this.#element.dataset.entryType = side.entryType;
 | 
			
		||||
        this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + side.entryType + "-modal";
 | 
			
		||||
        this.#summaryControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        this.#summaryControl.classList.remove("is-invalid");
 | 
			
		||||
        this.#summary.dataset.value = "";
 | 
			
		||||
        this.#summary.innerText = ""
 | 
			
		||||
        this.#summaryError.innerText = ""
 | 
			
		||||
        this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + side.entryType + "-modal";
 | 
			
		||||
        this.#accountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        this.#accountControl.classList.remove("is-invalid");
 | 
			
		||||
        this.#account.innerText = "";
 | 
			
		||||
        this.#account.dataset.code = "";
 | 
			
		||||
        this.#account.dataset.text = "";
 | 
			
		||||
        this.#accountError.innerText = "";
 | 
			
		||||
        this.#amount.value = "";
 | 
			
		||||
        this.#amount.classList.remove("is-invalid");
 | 
			
		||||
        this.#amountError.innerText = "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Edits a journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entry {JournalEntrySubForm} the journal entry sub-form
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     * @param accountCode {string} the account code
 | 
			
		||||
     * @param accountText {string} the account text
 | 
			
		||||
     * @param amount {string} the amount
 | 
			
		||||
     */
 | 
			
		||||
    #onEdit(entry, summary, accountCode, accountText, amount) {
 | 
			
		||||
        this.#entry = entry;
 | 
			
		||||
        this.#side = entry.side;
 | 
			
		||||
        this.entryType = this.#side.entryType;
 | 
			
		||||
        this.#element.dataset.entryType = entry.entryType;
 | 
			
		||||
        this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.entryType + "-modal";
 | 
			
		||||
        if (summary === "") {
 | 
			
		||||
            this.#summaryControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#summaryControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.#summary.dataset.value = summary;
 | 
			
		||||
        this.#summary.innerText = summary;
 | 
			
		||||
        this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + entry.entryType + "-modal";
 | 
			
		||||
        if (accountCode === "") {
 | 
			
		||||
            this.#accountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.#account.innerText = accountText;
 | 
			
		||||
        this.#account.dataset.code = accountCode;
 | 
			
		||||
        this.#account.dataset.text = accountText;
 | 
			
		||||
        this.#amount.value = amount;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The journal entry editor
 | 
			
		||||
     * @type {JournalEntryEditor}
 | 
			
		||||
     */
 | 
			
		||||
    static #editor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the journal entry editor.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    static initialize() {
 | 
			
		||||
        this.#editor = new JournalEntryEditor();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds a new journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param side {DebitCreditSideSubForm} the debit or credit side sub-form
 | 
			
		||||
     */
 | 
			
		||||
    static addNew(side) {
 | 
			
		||||
        this.#editor.#onAddNew(side);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Edits a journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entry {JournalEntrySubForm} the journal entry sub-form
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     * @param accountCode {string} the account code
 | 
			
		||||
     * @param accountText {string} the account text
 | 
			
		||||
     * @param amount {string} the amount
 | 
			
		||||
     */
 | 
			
		||||
    static edit(entry, summary, accountCode, accountText, amount) {
 | 
			
		||||
        this.#editor.#onEdit(entry, summary, accountCode, accountText, amount);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -58,25 +58,25 @@ class SummaryEditor {
 | 
			
		||||
    #entryType;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The current tab.
 | 
			
		||||
     * The current tab
 | 
			
		||||
     * @type {TabPlane}
 | 
			
		||||
     */
 | 
			
		||||
    currentTab;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The summary input.
 | 
			
		||||
     * The summary input
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    summary;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The number input.
 | 
			
		||||
     * The number input
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    number;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The note.
 | 
			
		||||
     * The note
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    note;
 | 
			
		||||
@@ -94,34 +94,10 @@ class SummaryEditor {
 | 
			
		||||
    #selectedAccount = null;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The modal of the journal entry form
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     * The journal entry editor
 | 
			
		||||
     * @type {JournalEntryEditor}
 | 
			
		||||
     */
 | 
			
		||||
    #entryFormModal;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control of the account on the journal entry form
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #formAccountControl;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The account on the journal entry form
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #formAccount;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The control of the summary on the journal entry form
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #formSummaryControl;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The summary on the journal entry form
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #formSummary;
 | 
			
		||||
    #entryEditor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab planes
 | 
			
		||||
@@ -145,13 +121,6 @@ class SummaryEditor {
 | 
			
		||||
        // noinspection JSValidateTypes
 | 
			
		||||
        this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
 | 
			
		||||
 | 
			
		||||
        // Things from the entry form
 | 
			
		||||
        this.#entryFormModal = document.getElementById("accounting-entry-form-modal");
 | 
			
		||||
        this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        this.#formAccount = document.getElementById("accounting-entry-form-account");
 | 
			
		||||
        this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
			
		||||
        this.#formSummary = document.getElementById("accounting-entry-form-summary");
 | 
			
		||||
 | 
			
		||||
        for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
 | 
			
		||||
            const tab = new cls(this);
 | 
			
		||||
            this.tabPlanes[tab.tabId()] = tab;
 | 
			
		||||
@@ -239,30 +208,24 @@ class SummaryEditor {
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #submit() {
 | 
			
		||||
        if (this.summary.value === "") {
 | 
			
		||||
            this.#formSummaryControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#formSummaryControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        if (this.#selectedAccount !== null) {
 | 
			
		||||
            this.#formAccountControl.classList.add("accounting-not-empty");
 | 
			
		||||
            this.#formAccount.dataset.code = this.#selectedAccount.dataset.code;
 | 
			
		||||
            this.#formAccount.dataset.text = this.#selectedAccount.dataset.text;
 | 
			
		||||
            this.#formAccount.innerText = this.#selectedAccount.dataset.text;
 | 
			
		||||
        }
 | 
			
		||||
        this.#formSummary.dataset.value = this.summary.value;
 | 
			
		||||
        this.#formSummary.innerText = this.summary.value;
 | 
			
		||||
        bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
 | 
			
		||||
        bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show();
 | 
			
		||||
        if (this.#selectedAccount !== null) {
 | 
			
		||||
            this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text);
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#entryEditor.saveSummary(this.summary.value);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the summary editor is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entryEditor {JournalEntryEditor} the journal entry editor
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     */
 | 
			
		||||
    #onOpen() {
 | 
			
		||||
    #onOpen(entryEditor, summary) {
 | 
			
		||||
        this.#entryEditor = entryEditor;
 | 
			
		||||
        this.#reset();
 | 
			
		||||
        this.summary.value = this.#formSummary.dataset.value;
 | 
			
		||||
        this.summary.value = summary;
 | 
			
		||||
        this.#onSummaryChange();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -290,22 +253,20 @@ class SummaryEditor {
 | 
			
		||||
     */
 | 
			
		||||
    static initialize() {
 | 
			
		||||
        const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
 | 
			
		||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
			
		||||
        const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
			
		||||
        for (const form of forms) {
 | 
			
		||||
            const editor = new SummaryEditor(form);
 | 
			
		||||
            this.#editors[editor.#entryType] = editor;
 | 
			
		||||
        }
 | 
			
		||||
        formSummaryControl.onclick = () => this.#editors[entryForm.dataset.entryType].#onOpen()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the summary editor for a new journal entry.
 | 
			
		||||
     * The callback when the summary editor is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entryType {string} the entry type, either "debit" or "credit"
 | 
			
		||||
     * @param entryEditor {JournalEntryEditor} the journal entry editor
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     */
 | 
			
		||||
    static initializeNewJournalEntry(entryType) {
 | 
			
		||||
        this.#editors[entryType].#onOpen();
 | 
			
		||||
    static start(entryEditor, summary) {
 | 
			
		||||
        this.#editors[entryEditor.entryType].#onOpen(entryEditor, summary);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -547,7 +508,7 @@ class TagTabPlane extends TabPlane {
 | 
			
		||||
        errorContainer.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Resets the tab plane input.
 | 
			
		||||
     *
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -41,9 +41,9 @@ First written: 2023/2/1
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
 | 
			
		||||
    <div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
 | 
			
		||||
    <div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
 | 
			
		||||
      <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
 | 
			
		||||
      <div id="accounting-base-content">
 | 
			
		||||
      <div id="accounting-base">
 | 
			
		||||
        {% if form.base_code.data %}
 | 
			
		||||
          {% if form.base_code.errors %}
 | 
			
		||||
            {{ A_("(Unknown)") }}
 | 
			
		||||
@@ -53,7 +53,7 @@ First written: 2023/2/1
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
 | 
			
		||||
    <div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
@@ -62,7 +62,7 @@ First written: 2023/2/1
 | 
			
		||||
    <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="form-check form-switch mb-3">
 | 
			
		||||
  <div id="accounting-is-offset-needed-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2"] %} d-none {% endif %}">
 | 
			
		||||
    <input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
 | 
			
		||||
    <label class="form-check-label" for="accounting-is-offset-needed">
 | 
			
		||||
      {{ A_("The entries in the account need offset.") }}
 | 
			
		||||
@@ -99,21 +99,21 @@ First written: 2023/2/1
 | 
			
		||||
          </label>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <ul id="accounting-base-option-list" class="list-group accounting-selector-list">
 | 
			
		||||
        <ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
 | 
			
		||||
          {% for base in form.base_options %}
 | 
			
		||||
          <li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
 | 
			
		||||
            {{ base }}
 | 
			
		||||
          </li>
 | 
			
		||||
            <li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
 | 
			
		||||
              {{ base }}
 | 
			
		||||
            </li>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        <p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
			
		||||
        <p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
        {% if form.base_code.data %}
 | 
			
		||||
          <button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
 | 
			
		||||
          <button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
 | 
			
		||||
        {% else %}
 | 
			
		||||
          <button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
 | 
			
		||||
          <button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -19,12 +19,12 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo
 | 
			
		||||
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 }}" data-prefix="accounting-currency-{{ currency_index }}">
 | 
			
		||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
			
		||||
  <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" class="form-select" name="currency-{{ currency_index }}-code">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
@@ -34,7 +34,7 @@ First written: 2023/2/25
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
          <i class="fas fa-minus"></i>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -70,7 +70,7 @@ First written: 2023/2/25
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div>
 | 
			
		||||
          <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
 | 
			
		||||
          <button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
 | 
			
		||||
            <i class="fas fa-plus"></i>
 | 
			
		||||
            {{ A_("New") }}
 | 
			
		||||
          </button>
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,7 @@ First written: 2023/2/25
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <div class="modal-header">
 | 
			
		||||
        <h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        <div class="input-group mb-2">
 | 
			
		||||
@@ -37,17 +37,17 @@ First written: 2023/2/25
 | 
			
		||||
 | 
			
		||||
        <ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
 | 
			
		||||
          {% for account in account_options %}
 | 
			
		||||
          <li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
 | 
			
		||||
            {{ account }}
 | 
			
		||||
          </li>
 | 
			
		||||
            <li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
 | 
			
		||||
              {{ account }}
 | 
			
		||||
            </li>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
          <li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
 | 
			
		||||
        </ul>
 | 
			
		||||
        <p id="accounting-account-selector-{{ entry_type }}-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-entry-form-modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
        <button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
        <button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Clear") }}</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,60 +0,0 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
entry-form-modal.html: The modal of the journal entry sub-form
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/25
 | 
			
		||||
#}
 | 
			
		||||
<form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index="">
 | 
			
		||||
  <div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-modal-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal-dialog">
 | 
			
		||||
      <div class="modal-content">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h1 class="modal-title fs-5" id="accounting-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
            <div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
 | 
			
		||||
              <label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label>
 | 
			
		||||
              <div id="accounting-entry-form-account" data-code="" data-text=""></div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
            <div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
 | 
			
		||||
              <label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
 | 
			
		||||
              <div id="accounting-entry-form-summary" data-value=""></div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="form-floating mb-3">
 | 
			
		||||
            <input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
 | 
			
		||||
            <label for="accounting-entry-form-amount">{{ A_("Amount") }}</label>
 | 
			
		||||
            <div id="accounting-entry-form-amount-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
          <button id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
@@ -20,16 +20,16 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/25
 | 
			
		||||
#}
 | 
			
		||||
{# <ul> For SonarQube not to complain about incorrect HTML #}
 | 
			
		||||
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
 | 
			
		||||
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}">
 | 
			
		||||
  {% if entry_id %}
 | 
			
		||||
    <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
 | 
			
		||||
  <div class="accounting-entry-content">
 | 
			
		||||
    <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
 | 
			
		||||
    <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
 | 
			
		||||
      <div>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
 | 
			
		||||
        <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div>
 | 
			
		||||
@@ -40,7 +40,7 @@ First written: 2023/2/25
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div>
 | 
			
		||||
    <button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
 | 
			
		||||
    <button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
 | 
			
		||||
      <i class="fas fa-minus"></i>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ First written: 2023/2/26
 | 
			
		||||
{% 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/transaction-form.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/journal-entry-editor.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -57,7 +58,7 @@ First written: 2023/2/26
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
 | 
			
		||||
        <button id="accounting-add-currency" class="btn btn-primary" type="button">
 | 
			
		||||
          <i class="fas fa-plus"></i>
 | 
			
		||||
          {{ A_("New") }}
 | 
			
		||||
        </button>
 | 
			
		||||
@@ -86,7 +87,7 @@ First written: 2023/2/26
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/transaction/include/entry-form-modal.html" %}
 | 
			
		||||
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
 | 
			
		||||
{% block form_modals %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,60 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
journal-entry-editor-modal.html: The modal of the journal entry editor
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
 Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 See the License for the specific language governing permissions and
 | 
			
		||||
 limitations under the License.
 | 
			
		||||
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/25
 | 
			
		||||
#}
 | 
			
		||||
<form id="accounting-entry-editor">
 | 
			
		||||
  <div id="accounting-entry-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-editor-modal-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal-dialog">
 | 
			
		||||
      <div class="modal-content">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h1 class="modal-title fs-5" id="accounting-entry-editor-modal-label">{{ A_("Journal Entry Content") }}</h1>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
            <div id="accounting-entry-editor-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
 | 
			
		||||
              <label class="form-label" for="accounting-entry-editor-summary">{{ A_("Summary") }}</label>
 | 
			
		||||
              <div id="accounting-entry-editor-summary" data-value=""></div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="accounting-entry-editor-summary-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
            <div id="accounting-entry-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
 | 
			
		||||
              <label class="form-label" for="accounting-entry-editor-account">{{ A_("Account") }}</label>
 | 
			
		||||
              <div id="accounting-entry-editor-account" data-code="" data-text=""></div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="accounting-entry-editor-account-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="form-floating mb-3">
 | 
			
		||||
            <input id="accounting-entry-editor-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
 | 
			
		||||
            <label for="accounting-entry-editor-amount">{{ A_("Amount") }}</label>
 | 
			
		||||
            <div id="accounting-entry-editor-amount-error" class="invalid-feedback"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
          <button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
@@ -27,7 +27,7 @@ First written: 2023/2/28
 | 
			
		||||
          <h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
 | 
			
		||||
            <label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label>
 | 
			
		||||
          </h1>
 | 
			
		||||
          <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
          <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
@@ -181,7 +181,7 @@ First written: 2023/2/28
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
          <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
          <button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -19,12 +19,12 @@ currency-sub-form.html: The currency sub-form in the cash income transaction for
 | 
			
		||||
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 }}" data-prefix="accounting-currency-{{ currency_index }}">
 | 
			
		||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
			
		||||
  <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" class="form-select" name="currency-{{ currency_index }}-code">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
@@ -34,7 +34,7 @@ First written: 2023/2/25
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
          <i class="fas fa-minus"></i>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -70,7 +70,7 @@ First written: 2023/2/25
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div>
 | 
			
		||||
          <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
 | 
			
		||||
          <button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
 | 
			
		||||
            <i class="fas fa-plus"></i>
 | 
			
		||||
            {{ A_("New") }}
 | 
			
		||||
          </button>
 | 
			
		||||
 
 | 
			
		||||
@@ -19,12 +19,12 @@ currency-sub-form.html: The currency sub-form in the transfer transaction 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 }}" data-prefix="accounting-currency-{{ currency_index }}">
 | 
			
		||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
 | 
			
		||||
  <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
 | 
			
		||||
  <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" class="form-select" name="currency-{{ currency_index }}-code">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
@@ -34,7 +34,7 @@ First written: 2023/2/25
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
        <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
 | 
			
		||||
          <i class="fas fa-minus"></i>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -72,7 +72,7 @@ First written: 2023/2/25
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div>
 | 
			
		||||
            <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
 | 
			
		||||
            <button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
 | 
			
		||||
              <i class="fas fa-plus"></i>
 | 
			
		||||
              {{ A_("New") }}
 | 
			
		||||
            </button>
 | 
			
		||||
@@ -88,10 +88,10 @@ First written: 2023/2/25
 | 
			
		||||
          <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
 | 
			
		||||
            {% for entry_form in credit_forms %}
 | 
			
		||||
              {% with currency_index = currency_index,
 | 
			
		||||
                      entry_id = entry_form.eid.data,
 | 
			
		||||
                      entry_type = "credit",
 | 
			
		||||
                      entry_index = loop.index,
 | 
			
		||||
                      only_one_entry_form = debit_forms|length == 1,
 | 
			
		||||
                      entry_id = entry_form.eid.data,
 | 
			
		||||
                      account_code_data = entry_form.account_code.data|accounting_default,
 | 
			
		||||
                      account_code_error = entry_form.account_code.errors,
 | 
			
		||||
                      account_text = entry_form.account_text,
 | 
			
		||||
@@ -112,7 +112,7 @@ First written: 2023/2/25
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div>
 | 
			
		||||
            <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
 | 
			
		||||
            <button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
 | 
			
		||||
              <i class="fas fa-plus"></i>
 | 
			
		||||
              {{ A_("New") }}
 | 
			
		||||
            </button>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										22
									
								
								src/accounting/transaction/forms/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/accounting/transaction/forms/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The forms for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from .reorder import sort_transactions_in, TransactionReorderForm
 | 
			
		||||
from .transaction import TransactionForm, IncomeTransactionForm, \
 | 
			
		||||
    ExpenseTransactionForm, TransferTransactionForm
 | 
			
		||||
							
								
								
									
										207
									
								
								src/accounting/transaction/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								src/accounting/transaction/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,207 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The currency sub-forms for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
 | 
			
		||||
    BooleanField, FormField
 | 
			
		||||
from wtforms.validators import DataRequired
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Currency
 | 
			
		||||
from accounting.utils.strip_text import strip_text
 | 
			
		||||
from .journal_entry import CreditEntryForm, DebitEntryForm
 | 
			
		||||
 | 
			
		||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
 | 
			
		||||
    lazy_gettext("Please select the currency."))
 | 
			
		||||
"""The validator to check if the currency code is empty."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyExists:
 | 
			
		||||
    """The validator to check if the account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if db.session.get(Currency, field.data) is None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The currency does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NeedSomeJournalEntries:
 | 
			
		||||
    """The validator to check if there is any journal entry sub-form."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: FieldList) -> None:
 | 
			
		||||
        if len(field) == 0:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "Please add some journal entries."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsBalanced:
 | 
			
		||||
    """The validator to check that the total amount of the debit and credit
 | 
			
		||||
    entries are equal."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: BooleanField) -> None:
 | 
			
		||||
        if not isinstance(form, TransferCurrencyForm):
 | 
			
		||||
            return
 | 
			
		||||
        if len(form.debit) == 0 or len(form.credit) == 0:
 | 
			
		||||
            return
 | 
			
		||||
        if form.debit_total != form.credit_total:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The totals of the debit and credit amounts do not match."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyForm(FlaskForm):
 | 
			
		||||
    """The form to create or edit a currency in a transaction."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField()
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    whole_form = BooleanField()
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeCurrencyForm(CurrencyForm):
 | 
			
		||||
    """The form to create or edit a currency in a cash income transaction."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[CURRENCY_REQUIRED,
 | 
			
		||||
                    CurrencyExists()])
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    credit = FieldList(FormField(CreditEntryForm),
 | 
			
		||||
                       validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The credit entries."""
 | 
			
		||||
    whole_form = BooleanField()
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the credit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the credit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.credit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the credit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.credit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExpenseCurrencyForm(CurrencyForm):
 | 
			
		||||
    """The form to create or edit a currency in a cash expense transaction."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[CURRENCY_REQUIRED,
 | 
			
		||||
                    CurrencyExists()])
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    debit = FieldList(FormField(DebitEntryForm),
 | 
			
		||||
                      validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The debit entries."""
 | 
			
		||||
    whole_form = BooleanField()
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the debit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the debit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.debit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the debit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.debit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransferCurrencyForm(CurrencyForm):
 | 
			
		||||
    """The form to create or edit a currency in a transfer transaction."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[CURRENCY_REQUIRED,
 | 
			
		||||
                    CurrencyExists()])
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    debit = FieldList(FormField(DebitEntryForm),
 | 
			
		||||
                      validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The debit entries."""
 | 
			
		||||
    credit = FieldList(FormField(CreditEntryForm),
 | 
			
		||||
                       validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The credit entries."""
 | 
			
		||||
    whole_form = BooleanField(validators=[IsBalanced()])
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the debit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the debit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.debit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the credit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the credit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.credit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the debit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.debit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the credit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.credit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
							
								
								
									
										194
									
								
								src/accounting/transaction/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								src/accounting/transaction/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,194 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The journal entry sub-forms for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField, ValidationError, DecimalField, IntegerField
 | 
			
		||||
from wtforms.validators import DataRequired
 | 
			
		||||
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Account, JournalEntry
 | 
			
		||||
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 AccountExists:
 | 
			
		||||
    """The validator to check if the account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if Account.find_by_code(field.data) is None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The account does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PositiveAmount:
 | 
			
		||||
    """The validator to check if the amount is positive."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if field.data <= 0:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "Please fill in a positive amount."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsDebitAccount:
 | 
			
		||||
    """The validator to check if the account is for debit journal entries."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[1235689]|7[5678])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(lazy_gettext(
 | 
			
		||||
            "This account is not for debit entries."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsCreditAccount:
 | 
			
		||||
    """The validator to check if the account is for credit journal entries."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[123489]|7[1234])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(lazy_gettext(
 | 
			
		||||
            "This account is not for credit entries."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalEntryForm(FlaskForm):
 | 
			
		||||
    """The base form to create or edit a journal entry."""
 | 
			
		||||
    eid = IntegerField()
 | 
			
		||||
    """The existing journal entry ID."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the currency."""
 | 
			
		||||
    account_code = StringField()
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    amount = DecimalField()
 | 
			
		||||
    """The amount."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_text(self) -> str:
 | 
			
		||||
        """Returns the text representation of the account.
 | 
			
		||||
 | 
			
		||||
        :return: The text representation of the account.
 | 
			
		||||
        """
 | 
			
		||||
        if self.account_code.data is None:
 | 
			
		||||
            return ""
 | 
			
		||||
        account: Account | None = Account.find_by_code(self.account_code.data)
 | 
			
		||||
        if account is None:
 | 
			
		||||
            return ""
 | 
			
		||||
        return str(account)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def all_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns all the errors of the form.
 | 
			
		||||
 | 
			
		||||
        :return: All the errors of the form.
 | 
			
		||||
        """
 | 
			
		||||
        all_errors: list[str | LazyString] = []
 | 
			
		||||
        for key in self.errors:
 | 
			
		||||
            if key != "csrf_token":
 | 
			
		||||
                all_errors.extend(self.errors[key])
 | 
			
		||||
        return all_errors
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DebitEntryForm(JournalEntryForm):
 | 
			
		||||
    """The form to create or edit a debit journal entry."""
 | 
			
		||||
    eid = IntegerField()
 | 
			
		||||
    """The existing journal entry ID."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the currency."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[ACCOUNT_REQUIRED,
 | 
			
		||||
                    AccountExists(),
 | 
			
		||||
                    IsDebitAccount()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    summary = StringField(filters=[strip_text])
 | 
			
		||||
    """The summary."""
 | 
			
		||||
    amount = DecimalField(validators=[PositiveAmount()])
 | 
			
		||||
    """The amount."""
 | 
			
		||||
 | 
			
		||||
    def populate_obj(self, obj: JournalEntry) -> None:
 | 
			
		||||
        """Populates the form data into a journal entry object.
 | 
			
		||||
 | 
			
		||||
        :param obj: The journal entry object.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        is_new: bool = obj.id is None
 | 
			
		||||
        if is_new:
 | 
			
		||||
            obj.id = new_id(JournalEntry)
 | 
			
		||||
        obj.account_id = Account.find_by_code(self.account_code.data).id
 | 
			
		||||
        obj.summary = self.summary.data
 | 
			
		||||
        obj.is_debit = True
 | 
			
		||||
        obj.amount = self.amount.data
 | 
			
		||||
        if is_new:
 | 
			
		||||
            current_user_pk: int = get_current_user_pk()
 | 
			
		||||
            obj.created_by_id = current_user_pk
 | 
			
		||||
            obj.updated_by_id = current_user_pk
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CreditEntryForm(JournalEntryForm):
 | 
			
		||||
    """The form to create or edit a credit journal entry."""
 | 
			
		||||
    eid = IntegerField()
 | 
			
		||||
    """The existing journal entry ID."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the currency."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[ACCOUNT_REQUIRED,
 | 
			
		||||
                    AccountExists(),
 | 
			
		||||
                    IsCreditAccount()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    summary = StringField(filters=[strip_text])
 | 
			
		||||
    """The summary."""
 | 
			
		||||
    amount = DecimalField(validators=[PositiveAmount()])
 | 
			
		||||
    """The amount."""
 | 
			
		||||
 | 
			
		||||
    def populate_obj(self, obj: JournalEntry) -> None:
 | 
			
		||||
        """Populates the form data into a journal entry object.
 | 
			
		||||
 | 
			
		||||
        :param obj: The journal entry object.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        is_new: bool = obj.id is None
 | 
			
		||||
        if is_new:
 | 
			
		||||
            obj.id = new_id(JournalEntry)
 | 
			
		||||
        obj.account_id = Account.find_by_code(self.account_code.data).id
 | 
			
		||||
        obj.summary = self.summary.data
 | 
			
		||||
        obj.is_debit = False
 | 
			
		||||
        obj.amount = self.amount.data
 | 
			
		||||
        if is_new:
 | 
			
		||||
            current_user_pk: int = get_current_user_pk()
 | 
			
		||||
            obj.created_by_id = current_user_pk
 | 
			
		||||
            obj.updated_by_id = current_user_pk
 | 
			
		||||
							
								
								
									
										89
									
								
								src/accounting/transaction/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/accounting/transaction/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The reorder forms for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
 | 
			
		||||
from flask import request
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import Transaction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def sort_transactions_in(txn_date: date, exclude: int) -> None:
 | 
			
		||||
    """Sorts the transactions under a date after changing the date or deleting
 | 
			
		||||
    a transaction.
 | 
			
		||||
 | 
			
		||||
    :param txn_date: The date of the transaction.
 | 
			
		||||
    :param exclude: The transaction ID to exclude.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    transactions: list[Transaction] = Transaction.query\
 | 
			
		||||
        .filter(Transaction.date == txn_date,
 | 
			
		||||
                Transaction.id != exclude)\
 | 
			
		||||
        .order_by(Transaction.no).all()
 | 
			
		||||
    for i in range(len(transactions)):
 | 
			
		||||
        if transactions[i].no != i + 1:
 | 
			
		||||
            transactions[i].no = i + 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionReorderForm:
 | 
			
		||||
    """The form to reorder the transactions."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, txn_date: date):
 | 
			
		||||
        """Constructs the form to reorder the transactions in a day.
 | 
			
		||||
 | 
			
		||||
        :param txn_date: The date.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: date = txn_date
 | 
			
		||||
        self.is_modified: bool = False
 | 
			
		||||
 | 
			
		||||
    def save_order(self) -> None:
 | 
			
		||||
        """Saves the order of the account.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        transactions: list[Transaction] = Transaction.query\
 | 
			
		||||
            .filter(Transaction.date == self.date).all()
 | 
			
		||||
 | 
			
		||||
        # Collects the specified order.
 | 
			
		||||
        orders: dict[Transaction, int] = {}
 | 
			
		||||
        for txn in transactions:
 | 
			
		||||
            if f"{txn.id}-no" in request.form:
 | 
			
		||||
                try:
 | 
			
		||||
                    orders[txn] = int(request.form[f"{txn.id}-no"])
 | 
			
		||||
                except ValueError:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
        # Missing and invalid orders are appended to the end.
 | 
			
		||||
        missing: list[Transaction] \
 | 
			
		||||
            = [x for x in transactions if x not in orders]
 | 
			
		||||
        if len(missing) > 0:
 | 
			
		||||
            next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
 | 
			
		||||
            for txn in missing:
 | 
			
		||||
                orders[txn] = next_no
 | 
			
		||||
 | 
			
		||||
        # Sort by the specified order first, and their original order.
 | 
			
		||||
        transactions.sort(key=lambda x: (orders[x], x.no))
 | 
			
		||||
 | 
			
		||||
        # Update the orders.
 | 
			
		||||
        with db.session.no_autoflush:
 | 
			
		||||
            for i in range(len(transactions)):
 | 
			
		||||
                if transactions[i].no != i + 1:
 | 
			
		||||
                    transactions[i].no = i + 1
 | 
			
		||||
                    self.is_modified = True
 | 
			
		||||
@@ -14,38 +14,33 @@
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The forms for the transaction management.
 | 
			
		||||
"""The transaction forms for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import request
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import DateField, StringField, FieldList, FormField, \
 | 
			
		||||
    IntegerField, TextAreaField, DecimalField, BooleanField
 | 
			
		||||
from wtforms import DateField, FieldList, FormField, \
 | 
			
		||||
    TextAreaField
 | 
			
		||||
from wtforms.validators import DataRequired, ValidationError
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import Transaction, Account, JournalEntry, \
 | 
			
		||||
    TransactionCurrency, Currency
 | 
			
		||||
from accounting.transaction.summary_editor import SummaryEditor
 | 
			
		||||
    TransactionCurrency
 | 
			
		||||
from accounting.transaction.utils.account_option import AccountOption
 | 
			
		||||
from accounting.transaction.utils.summary_editor import SummaryEditor
 | 
			
		||||
from accounting.utils.random_id import new_id
 | 
			
		||||
from accounting.utils.strip_text import strip_text, strip_multiline_text
 | 
			
		||||
from accounting.utils.strip_text import strip_multiline_text
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \
 | 
			
		||||
    TransferCurrencyForm
 | 
			
		||||
from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm
 | 
			
		||||
from .reorder import sort_transactions_in
 | 
			
		||||
 | 
			
		||||
MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.")
 | 
			
		||||
"""The error message when the currency code is empty."""
 | 
			
		||||
MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.")
 | 
			
		||||
"""The error message when the account code is empty."""
 | 
			
		||||
DATE_REQUIRED: DataRequired = DataRequired(
 | 
			
		||||
    lazy_gettext("Please fill in the date."))
 | 
			
		||||
"""The validator to check if the date is empty."""
 | 
			
		||||
@@ -54,228 +49,9 @@ DATE_REQUIRED: DataRequired = DataRequired(
 | 
			
		||||
class NeedSomeCurrencies:
 | 
			
		||||
    """The validator to check if there is any currency sub-form."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: CurrencyForm, field: FieldList) \
 | 
			
		||||
            -> None:
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: FieldList) -> None:
 | 
			
		||||
        if len(field) == 0:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "Please add some currencies."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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 NeedSomeJournalEntries:
 | 
			
		||||
    """The validator to check if there is any journal entry sub-form."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: TransferCurrencyForm, field: FieldList) \
 | 
			
		||||
            -> None:
 | 
			
		||||
        if len(field) == 0:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "Please add some journal entries."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountExists:
 | 
			
		||||
    """The validator to check if the account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if Account.find_by_code(field.data) is None:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The account does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PositiveAmount:
 | 
			
		||||
    """The validator to check if the amount is positive."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if field.data <= 0:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "Please fill in a positive amount."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsDebitAccount:
 | 
			
		||||
    """The validator to check if the account is for debit journal entries."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[1235689]|7[5678])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(lazy_gettext(
 | 
			
		||||
            "This account is not for debit entries."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountOption:
 | 
			
		||||
    """An account option."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account):
 | 
			
		||||
        """Constructs an account option.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        """
 | 
			
		||||
        self.id: str = account.id
 | 
			
		||||
        """The account ID."""
 | 
			
		||||
        self.code: str = account.code
 | 
			
		||||
        """The account code."""
 | 
			
		||||
        self.query_values: list[str] = account.query_values
 | 
			
		||||
        """The values to be queried."""
 | 
			
		||||
        self.__str: str = str(account)
 | 
			
		||||
        """The string representation of the account option."""
 | 
			
		||||
        self.is_in_use: bool = False
 | 
			
		||||
        """True if this account is in use, or False otherwise."""
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        """Returns the string representation of the account option.
 | 
			
		||||
 | 
			
		||||
        :return: The string representation of the account option.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalEntryForm(FlaskForm):
 | 
			
		||||
    """The base form to create or edit a journal entry."""
 | 
			
		||||
    eid = IntegerField()
 | 
			
		||||
    """The existing journal entry ID."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the currency."""
 | 
			
		||||
    account_code = StringField()
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    amount = DecimalField()
 | 
			
		||||
    """The amount."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_text(self) -> str:
 | 
			
		||||
        """Returns the text representation of the account.
 | 
			
		||||
 | 
			
		||||
        :return: The text representation of the account.
 | 
			
		||||
        """
 | 
			
		||||
        if self.account_code.data is None:
 | 
			
		||||
            return ""
 | 
			
		||||
        account: Account | None = Account.find_by_code(self.account_code.data)
 | 
			
		||||
        if account is None:
 | 
			
		||||
            return ""
 | 
			
		||||
        return str(account)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def all_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns all the errors of the form.
 | 
			
		||||
 | 
			
		||||
        :return: All the errors of the form.
 | 
			
		||||
        """
 | 
			
		||||
        all_errors: list[str | LazyString] = []
 | 
			
		||||
        for key in self.errors:
 | 
			
		||||
            if key != "csrf_token":
 | 
			
		||||
                all_errors.extend(self.errors[key])
 | 
			
		||||
        return all_errors
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DebitEntryForm(JournalEntryForm):
 | 
			
		||||
    """The form to create or edit a debit journal entry."""
 | 
			
		||||
    eid = IntegerField()
 | 
			
		||||
    """The existing journal entry ID."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the currency."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(MISSING_ACCOUNT),
 | 
			
		||||
                    AccountExists(),
 | 
			
		||||
                    IsDebitAccount()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    summary = StringField(filters=[strip_text])
 | 
			
		||||
    """The summary."""
 | 
			
		||||
    amount = DecimalField(validators=[PositiveAmount()])
 | 
			
		||||
    """The amount."""
 | 
			
		||||
 | 
			
		||||
    def populate_obj(self, obj: JournalEntry) -> None:
 | 
			
		||||
        """Populates the form data into a journal entry object.
 | 
			
		||||
 | 
			
		||||
        :param obj: The journal entry object.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        is_new: bool = obj.id is None
 | 
			
		||||
        if is_new:
 | 
			
		||||
            obj.id = new_id(JournalEntry)
 | 
			
		||||
        obj.account_id = Account.find_by_code(self.account_code.data).id
 | 
			
		||||
        obj.summary = self.summary.data
 | 
			
		||||
        obj.is_debit = True
 | 
			
		||||
        obj.amount = self.amount.data
 | 
			
		||||
        if is_new:
 | 
			
		||||
            current_user_pk: int = get_current_user_pk()
 | 
			
		||||
            obj.created_by_id = current_user_pk
 | 
			
		||||
            obj.updated_by_id = current_user_pk
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsCreditAccount:
 | 
			
		||||
    """The validator to check if the account is for credit journal entries."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[123489]|7[1234])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(lazy_gettext(
 | 
			
		||||
            "This account is not for credit entries."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CreditEntryForm(JournalEntryForm):
 | 
			
		||||
    """The form to create or edit a credit journal entry."""
 | 
			
		||||
    eid = IntegerField()
 | 
			
		||||
    """The existing journal entry ID."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the currency."""
 | 
			
		||||
    account_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(MISSING_ACCOUNT),
 | 
			
		||||
                    AccountExists(),
 | 
			
		||||
                    IsCreditAccount()])
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    summary = StringField(filters=[strip_text])
 | 
			
		||||
    """The summary."""
 | 
			
		||||
    amount = DecimalField(validators=[PositiveAmount()])
 | 
			
		||||
    """The amount."""
 | 
			
		||||
 | 
			
		||||
    def populate_obj(self, obj: JournalEntry) -> None:
 | 
			
		||||
        """Populates the form data into a journal entry object.
 | 
			
		||||
 | 
			
		||||
        :param obj: The journal entry object.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        is_new: bool = obj.id is None
 | 
			
		||||
        if is_new:
 | 
			
		||||
            obj.id = new_id(JournalEntry)
 | 
			
		||||
        obj.account_id = Account.find_by_code(self.account_code.data).id
 | 
			
		||||
        obj.summary = self.summary.data
 | 
			
		||||
        obj.is_debit = False
 | 
			
		||||
        obj.amount = self.amount.data
 | 
			
		||||
        if is_new:
 | 
			
		||||
            current_user_pk: int = get_current_user_pk()
 | 
			
		||||
            obj.created_by_id = current_user_pk
 | 
			
		||||
            obj.updated_by_id = current_user_pk
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyForm(FlaskForm):
 | 
			
		||||
    """The form to create or edit a currency in a transaction."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField()
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    whole_form = BooleanField()
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
            raise ValidationError(lazy_gettext("Please add some currencies."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionForm(FlaskForm):
 | 
			
		||||
@@ -300,8 +76,6 @@ class TransactionForm(FlaskForm):
 | 
			
		||||
        """The journal entry collector.  The default is the base abstract
 | 
			
		||||
        collector only to provide the correct type.  The subclass forms should
 | 
			
		||||
        provide their own collectors."""
 | 
			
		||||
        self.__in_use_account_id: set[int] | None = None
 | 
			
		||||
        """The ID of the accounts that are in use."""
 | 
			
		||||
 | 
			
		||||
    def populate_obj(self, obj: Transaction) -> None:
 | 
			
		||||
        """Populates the form data into a transaction object.
 | 
			
		||||
@@ -538,41 +312,6 @@ class JournalEntryCollector(t.Generic[T], ABC):
 | 
			
		||||
                                  ord_by_form.get(x)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeCurrencyForm(CurrencyForm):
 | 
			
		||||
    """The form to create or edit a currency in a cash income transaction."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(MISSING_CURRENCY),
 | 
			
		||||
                    CurrencyExists()])
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    credit = FieldList(FormField(CreditEntryForm),
 | 
			
		||||
                       validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The credit entries."""
 | 
			
		||||
    whole_form = BooleanField()
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the credit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the credit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.credit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the credit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.credit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeTransactionForm(TransactionForm):
 | 
			
		||||
    """The form to create or edit a cash income transaction."""
 | 
			
		||||
    date = DateField(validators=[DATE_REQUIRED])
 | 
			
		||||
@@ -611,41 +350,6 @@ class IncomeTransactionForm(TransactionForm):
 | 
			
		||||
        self.collector = Collector
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExpenseCurrencyForm(CurrencyForm):
 | 
			
		||||
    """The form to create or edit a currency in a cash expense transaction."""
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(MISSING_CURRENCY),
 | 
			
		||||
                    CurrencyExists()])
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    debit = FieldList(FormField(DebitEntryForm),
 | 
			
		||||
                      validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The debit entries."""
 | 
			
		||||
    whole_form = BooleanField()
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the debit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the debit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.debit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the debit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.debit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExpenseTransactionForm(TransactionForm):
 | 
			
		||||
    """The form to create or edit a cash expense transaction."""
 | 
			
		||||
    date = DateField(validators=[DATE_REQUIRED])
 | 
			
		||||
@@ -685,76 +389,6 @@ class ExpenseTransactionForm(TransactionForm):
 | 
			
		||||
        self.collector = Collector
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransferCurrencyForm(CurrencyForm):
 | 
			
		||||
    """The form to create or edit a currency in a transfer transaction."""
 | 
			
		||||
 | 
			
		||||
    class IsBalanced:
 | 
			
		||||
        """The validator to check that the total amount of the debit and credit
 | 
			
		||||
        entries are equal."""
 | 
			
		||||
        def __call__(self, form: TransferCurrencyForm, field: BooleanField)\
 | 
			
		||||
                -> None:
 | 
			
		||||
            if len(form.debit) == 0 or len(form.credit) == 0:
 | 
			
		||||
                return
 | 
			
		||||
            if form.debit_total != form.credit_total:
 | 
			
		||||
                raise ValidationError(lazy_gettext(
 | 
			
		||||
                    "The totals of the debit and credit amounts do not"
 | 
			
		||||
                    " match."))
 | 
			
		||||
 | 
			
		||||
    no = IntegerField()
 | 
			
		||||
    """The order in the transaction."""
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(MISSING_CURRENCY),
 | 
			
		||||
                    CurrencyExists()])
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    debit = FieldList(FormField(DebitEntryForm),
 | 
			
		||||
                      validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The debit entries."""
 | 
			
		||||
    credit = FieldList(FormField(CreditEntryForm),
 | 
			
		||||
                       validators=[NeedSomeJournalEntries()])
 | 
			
		||||
    """The credit entries."""
 | 
			
		||||
    whole_form = BooleanField(validators=[IsBalanced()])
 | 
			
		||||
    """The pseudo field for the whole form validators."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the debit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the debit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.debit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_total(self) -> Decimal:
 | 
			
		||||
        """Returns the total amount of the credit journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The total amount of the credit journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount.data for x in self.credit
 | 
			
		||||
                    if x.amount.data is not None])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def debit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the debit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.debit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit_errors(self) -> list[str | LazyString]:
 | 
			
		||||
        """Returns the credit journal entry errors, without the errors in their
 | 
			
		||||
        sub-forms.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        return [x for x in self.credit.errors
 | 
			
		||||
                if isinstance(x, str) or isinstance(x, LazyString)]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransferTransactionForm(TransactionForm):
 | 
			
		||||
    """The form to create or edit a transfer transaction."""
 | 
			
		||||
    date = DateField(validators=[DATE_REQUIRED])
 | 
			
		||||
@@ -795,67 +429,3 @@ class TransferTransactionForm(TransactionForm):
 | 
			
		||||
                        self._credit_no = self._credit_no + 1
 | 
			
		||||
 | 
			
		||||
        self.collector = Collector
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def sort_transactions_in(txn_date: date, exclude: int) -> None:
 | 
			
		||||
    """Sorts the transactions under a date after changing the date or deleting
 | 
			
		||||
    a transaction.
 | 
			
		||||
 | 
			
		||||
    :param txn_date: The date of the transaction.
 | 
			
		||||
    :param exclude: The transaction ID to exclude.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    transactions: list[Transaction] = Transaction.query\
 | 
			
		||||
        .filter(Transaction.date == txn_date,
 | 
			
		||||
                Transaction.id != exclude)\
 | 
			
		||||
        .order_by(Transaction.no).all()
 | 
			
		||||
    for i in range(len(transactions)):
 | 
			
		||||
        if transactions[i].no != i + 1:
 | 
			
		||||
            transactions[i].no = i + 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionReorderForm:
 | 
			
		||||
    """The form to reorder the transactions."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, txn_date: date):
 | 
			
		||||
        """Constructs the form to reorder the transactions in a day.
 | 
			
		||||
 | 
			
		||||
        :param txn_date: The date.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: date = txn_date
 | 
			
		||||
        self.is_modified: bool = False
 | 
			
		||||
 | 
			
		||||
    def save_order(self) -> None:
 | 
			
		||||
        """Saves the order of the account.
 | 
			
		||||
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        transactions: list[Transaction] = Transaction.query\
 | 
			
		||||
            .filter(Transaction.date == self.date).all()
 | 
			
		||||
 | 
			
		||||
        # Collects the specified order.
 | 
			
		||||
        orders: dict[Transaction, int] = {}
 | 
			
		||||
        for txn in transactions:
 | 
			
		||||
            if f"{txn.id}-no" in request.form:
 | 
			
		||||
                try:
 | 
			
		||||
                    orders[txn] = int(request.form[f"{txn.id}-no"])
 | 
			
		||||
                except ValueError:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
        # Missing and invalid orders are appended to the end.
 | 
			
		||||
        missing: list[Transaction] \
 | 
			
		||||
            = [x for x in transactions if x not in orders]
 | 
			
		||||
        if len(missing) > 0:
 | 
			
		||||
            next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
 | 
			
		||||
            for txn in missing:
 | 
			
		||||
                orders[txn] = next_no
 | 
			
		||||
 | 
			
		||||
        # Sort by the specified order first, and their original order.
 | 
			
		||||
        transactions.sort(key=lambda x: (orders[x], x.no))
 | 
			
		||||
 | 
			
		||||
        # Update the orders.
 | 
			
		||||
        with db.session.no_autoflush:
 | 
			
		||||
            for i in range(len(transactions)):
 | 
			
		||||
                if transactions[i].no != i + 1:
 | 
			
		||||
                    transactions[i].no = i + 1
 | 
			
		||||
                    self.is_modified = True
 | 
			
		||||
							
								
								
									
										19
									
								
								src/accounting/transaction/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/accounting/transaction/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The utilities for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
							
								
								
									
										47
									
								
								src/accounting/transaction/utils/account_option.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/accounting/transaction/utils/account_option.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The account option for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountOption:
 | 
			
		||||
    """An account option."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account):
 | 
			
		||||
        """Constructs an account option.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        """
 | 
			
		||||
        self.id: str = account.id
 | 
			
		||||
        """The account ID."""
 | 
			
		||||
        self.code: str = account.code
 | 
			
		||||
        """The account code."""
 | 
			
		||||
        self.query_values: list[str] = account.query_values
 | 
			
		||||
        """The values to be queried."""
 | 
			
		||||
        self.__str: str = str(account)
 | 
			
		||||
        """The string representation of the account option."""
 | 
			
		||||
        self.is_in_use: bool = False
 | 
			
		||||
        """True if this account is in use, or False otherwise."""
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        """Returns the string representation of the account option.
 | 
			
		||||
 | 
			
		||||
        :return: The string representation of the account option.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__str
 | 
			
		||||
@@ -26,8 +26,8 @@ from flask_wtf import FlaskForm
 | 
			
		||||
from accounting.models import Transaction
 | 
			
		||||
from accounting.template_globals import default_currency_code
 | 
			
		||||
from accounting.utils.txn_types import TransactionType
 | 
			
		||||
from .forms import TransactionForm, IncomeTransactionForm, \
 | 
			
		||||
    ExpenseTransactionForm, TransferTransactionForm
 | 
			
		||||
from accounting.transaction.forms import TransactionForm, \
 | 
			
		||||
    IncomeTransactionForm, ExpenseTransactionForm, TransferTransactionForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionOperator(ABC):
 | 
			
		||||
@@ -34,9 +34,9 @@ from accounting.utils.permission import has_permission, can_view, can_edit
 | 
			
		||||
from accounting.utils.txn_types import TransactionType
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
from .forms import sort_transactions_in, TransactionReorderForm
 | 
			
		||||
from .operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
 | 
			
		||||
from .template_filters import with_type, to_transfer, format_amount_input, \
 | 
			
		||||
    text2html
 | 
			
		||||
from .utils.operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("transaction", __name__)
 | 
			
		||||
"""The view blueprint for the transaction management."""
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ import typing as t
 | 
			
		||||
 | 
			
		||||
from flask import abort, Blueprint
 | 
			
		||||
 | 
			
		||||
from accounting.utils.user import get_current_user
 | 
			
		||||
from accounting.utils.user import get_current_user, UserUtilityInterface
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
 | 
			
		||||
@@ -87,22 +87,15 @@ def can_edit() -> bool:
 | 
			
		||||
    return __can_edit_func()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(bp: Blueprint,
 | 
			
		||||
             can_view_func: t.Callable[[], bool] | None = None,
 | 
			
		||||
             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
			
		||||
def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
 | 
			
		||||
    """Initializes the application.
 | 
			
		||||
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :param can_view_func: A callback that returns whether the current user can
 | 
			
		||||
        view the accounting data.
 | 
			
		||||
    :param can_edit_func: A callback that returns whether the current user can
 | 
			
		||||
        edit the accounting data.
 | 
			
		||||
    :param user_utils: The user utilities.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    global __can_view_func, __can_edit_func
 | 
			
		||||
    if can_view_func is not None:
 | 
			
		||||
        __can_view_func = can_view_func
 | 
			
		||||
    if can_edit_func is not None:
 | 
			
		||||
        __can_edit_func = can_edit_func
 | 
			
		||||
    bp.add_app_template_global(can_view, "accounting_can_view")
 | 
			
		||||
    bp.add_app_template_global(can_edit, "accounting_can_edit")
 | 
			
		||||
    __can_view_func = user_utils.can_view
 | 
			
		||||
    __can_edit_func = user_utils.can_edit
 | 
			
		||||
    bp.add_app_template_global(user_utils.can_view, "accounting_can_view")
 | 
			
		||||
    bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit")
 | 
			
		||||
 
 | 
			
		||||
@@ -29,15 +29,33 @@ from flask_sqlalchemy.model import Model
 | 
			
		||||
T = t.TypeVar("T", bound=Model)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AbstractUserUtils(t.Generic[T], ABC):
 | 
			
		||||
    """The abstract user utilities."""
 | 
			
		||||
class UserUtilityInterface(t.Generic[T], ABC):
 | 
			
		||||
    """The interface for the user utilities."""
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def can_view(self) -> bool:
 | 
			
		||||
        """Returns whether the currently logged-in user can view the accounting
 | 
			
		||||
        data.
 | 
			
		||||
 | 
			
		||||
        :return: True if the currently logged-in user can view the accounting
 | 
			
		||||
            data, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def can_edit(self) -> bool:
 | 
			
		||||
        """Returns whether the currently logged-in user can edit the accounting
 | 
			
		||||
        data.
 | 
			
		||||
 | 
			
		||||
        :return: True if the currently logged-in user can edit the accounting
 | 
			
		||||
            data, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def cls(self) -> t.Type[T]:
 | 
			
		||||
        """Returns the user class.
 | 
			
		||||
        """Returns the class of the user data model.
 | 
			
		||||
 | 
			
		||||
        :return: The user class.
 | 
			
		||||
        :return: The class of the user data model.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
@@ -66,13 +84,13 @@ class AbstractUserUtils(t.Generic[T], ABC):
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def get_pk(self, user: T) -> int:
 | 
			
		||||
        """Returns the primary key of the user.
 | 
			
		||||
        """Returns the primary key of the user, as an integer.
 | 
			
		||||
 | 
			
		||||
        :return: The primary key of the user.
 | 
			
		||||
        :return: The primary key of the user, as an integer.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
__user_utils: AbstractUserUtils
 | 
			
		||||
__user_utils: UserUtilityInterface
 | 
			
		||||
"""The user utilities."""
 | 
			
		||||
user_cls: t.Type[Model] = Model
 | 
			
		||||
"""The user class."""
 | 
			
		||||
@@ -80,7 +98,7 @@ user_pk_column: sa.Column = sa.Column(sa.Integer)
 | 
			
		||||
"""The primary key column of the user class."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_user_utils(utils: AbstractUserUtils) -> None:
 | 
			
		||||
def init_user_utils(utils: UserUtilityInterface) -> None:
 | 
			
		||||
    """Initializes the user utilities.
 | 
			
		||||
 | 
			
		||||
    :param utils: The user utilities.
 | 
			
		||||
 
 | 
			
		||||
@@ -26,8 +26,8 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import create_app, db
 | 
			
		||||
from testlib import get_client, set_locale
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client, set_locale
 | 
			
		||||
 | 
			
		||||
NEXT_URI: str = "/_next"
 | 
			
		||||
"""The next URI."""
 | 
			
		||||
@@ -74,7 +74,7 @@ class AccountCommandTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -127,7 +127,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -372,6 +372,15 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # A nominal account that needs offset
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "6172",
 | 
			
		||||
                                          "title": stock.title,
 | 
			
		||||
                                          "is_offset_needed": "yes"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # Success, with spaces to be stripped
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
@@ -470,6 +479,15 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # A nominal account that needs offset
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "6172",
 | 
			
		||||
                                          "title": stock.title,
 | 
			
		||||
                                          "is_offset_needed": "yes"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # Change the base account
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
 
 | 
			
		||||
@@ -26,8 +26,7 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import create_app
 | 
			
		||||
from testlib import get_client
 | 
			
		||||
from testlib import create_test_app, get_client
 | 
			
		||||
 | 
			
		||||
LIST_URI: str = "/accounting/base-accounts"
 | 
			
		||||
"""The list URI."""
 | 
			
		||||
@@ -45,7 +44,7 @@ class BaseAccountCommandTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import BaseAccount, BaseAccountL10n
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -98,7 +97,7 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import BaseAccount
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
 
 | 
			
		||||
@@ -27,8 +27,8 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import create_app, db
 | 
			
		||||
from testlib import get_client, set_locale
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client, set_locale
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyData:
 | 
			
		||||
@@ -67,7 +67,7 @@ class CurrencyCommandTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -123,7 +123,7 @@ class CurrencyTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
 
 | 
			
		||||
@@ -29,8 +29,6 @@ from flask_sqlalchemy import SQLAlchemy
 | 
			
		||||
from flask_wtf import CSRFProtect
 | 
			
		||||
from sqlalchemy import Column
 | 
			
		||||
 | 
			
		||||
import accounting.utils.user
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("home", __name__)
 | 
			
		||||
babel_js: BabelJS = BabelJS()
 | 
			
		||||
csrf: CSRFProtect = CSRFProtect()
 | 
			
		||||
@@ -69,7 +67,16 @@ def create_app(is_testing: bool = False) -> Flask:
 | 
			
		||||
    from . import auth
 | 
			
		||||
    auth.init_app(app)
 | 
			
		||||
 | 
			
		||||
    class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
 | 
			
		||||
    class UserUtilities(accounting.UserUtilityInterface[auth.User]):
 | 
			
		||||
 | 
			
		||||
        def can_view(self) -> bool:
 | 
			
		||||
            return auth.current_user() is not None \
 | 
			
		||||
                and auth.current_user().username in ["viewer", "editor",
 | 
			
		||||
                                                     "editor2"]
 | 
			
		||||
 | 
			
		||||
        def can_edit(self) -> bool:
 | 
			
		||||
            return auth.current_user() is not None \
 | 
			
		||||
                and auth.current_user().username in ["editor", "editor2"]
 | 
			
		||||
 | 
			
		||||
        @property
 | 
			
		||||
        def cls(self) -> t.Type[auth.User]:
 | 
			
		||||
@@ -90,12 +97,7 @@ def create_app(is_testing: bool = False) -> Flask:
 | 
			
		||||
        def get_pk(self, user: auth.User) -> int:
 | 
			
		||||
            return user.id
 | 
			
		||||
 | 
			
		||||
    can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
			
		||||
        and auth.current_user().username in ["viewer", "editor", "editor2"]
 | 
			
		||||
    can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
			
		||||
        and auth.current_user().username in ["editor", "editor2"]
 | 
			
		||||
    accounting.init_app(app, user_utils=UserUtils(),
 | 
			
		||||
                        can_view_func=can_view, can_edit_func=can_edit)
 | 
			
		||||
    accounting.init_app(app, user_utils=UserUtilities())
 | 
			
		||||
 | 
			
		||||
    return app
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,7 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import create_app
 | 
			
		||||
from testlib import get_client
 | 
			
		||||
from testlib import create_test_app, get_client
 | 
			
		||||
from testlib_txn import Accounts, NEXT_URI, add_txn
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -38,7 +37,7 @@ class SummeryEditorTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -66,7 +65,7 @@ class SummeryEditorTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.transaction.summary_editor import SummaryEditor
 | 
			
		||||
        from accounting.transaction.utils.summary_editor import SummaryEditor
 | 
			
		||||
        for form in get_form_data(self.csrf_token):
 | 
			
		||||
            add_txn(self.client, form)
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -79,13 +78,13 @@ class SummeryEditorTestCase(unittest.TestCase):
 | 
			
		||||
        self.assertEqual(editor.debit.general.tags[0].accounts[0].code,
 | 
			
		||||
                         Accounts.MEAL)
 | 
			
		||||
        self.assertEqual(editor.debit.general.tags[0].accounts[1].code,
 | 
			
		||||
                         Accounts.PAYABLE)
 | 
			
		||||
                         Accounts.PETTY_CASH)
 | 
			
		||||
        self.assertEqual(editor.debit.general.tags[1].name, "Dinner")
 | 
			
		||||
        self.assertEqual(len(editor.debit.general.tags[1].accounts), 2)
 | 
			
		||||
        self.assertEqual(editor.debit.general.tags[1].accounts[0].code,
 | 
			
		||||
                         Accounts.MEAL)
 | 
			
		||||
        self.assertEqual(editor.debit.general.tags[1].accounts[1].code,
 | 
			
		||||
                         Accounts.PAYABLE)
 | 
			
		||||
                         Accounts.PETTY_CASH)
 | 
			
		||||
 | 
			
		||||
        # Debit-Travel
 | 
			
		||||
        self.assertEqual(len(editor.debit.travel.tags), 3)
 | 
			
		||||
@@ -118,7 +117,7 @@ class SummeryEditorTestCase(unittest.TestCase):
 | 
			
		||||
        self.assertEqual(editor.credit.general.tags[0].name, "Lunch")
 | 
			
		||||
        self.assertEqual(len(editor.credit.general.tags[0].accounts), 3)
 | 
			
		||||
        self.assertEqual(editor.credit.general.tags[0].accounts[0].code,
 | 
			
		||||
                         Accounts.PAYABLE)
 | 
			
		||||
                         Accounts.PETTY_CASH)
 | 
			
		||||
        self.assertEqual(editor.credit.general.tags[0].accounts[1].code,
 | 
			
		||||
                         Accounts.BANK)
 | 
			
		||||
        self.assertEqual(editor.credit.general.tags[0].accounts[2].code,
 | 
			
		||||
@@ -128,20 +127,20 @@ class SummeryEditorTestCase(unittest.TestCase):
 | 
			
		||||
        self.assertEqual(editor.credit.general.tags[1].accounts[0].code,
 | 
			
		||||
                         Accounts.BANK)
 | 
			
		||||
        self.assertEqual(editor.credit.general.tags[1].accounts[1].code,
 | 
			
		||||
                         Accounts.PAYABLE)
 | 
			
		||||
                         Accounts.PETTY_CASH)
 | 
			
		||||
 | 
			
		||||
        # Credit-Travel
 | 
			
		||||
        self.assertEqual(len(editor.credit.travel.tags), 2)
 | 
			
		||||
        self.assertEqual(editor.credit.travel.tags[0].name, "Bike")
 | 
			
		||||
        self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2)
 | 
			
		||||
        self.assertEqual(editor.credit.travel.tags[0].accounts[0].code,
 | 
			
		||||
                         Accounts.PAYABLE)
 | 
			
		||||
                         Accounts.PETTY_CASH)
 | 
			
		||||
        self.assertEqual(editor.credit.travel.tags[0].accounts[1].code,
 | 
			
		||||
                         Accounts.PREPAID)
 | 
			
		||||
        self.assertEqual(editor.credit.travel.tags[1].name, "Taxi")
 | 
			
		||||
        self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2)
 | 
			
		||||
        self.assertEqual(editor.credit.travel.tags[1].accounts[0].code,
 | 
			
		||||
                         Accounts.PAYABLE)
 | 
			
		||||
                         Accounts.PETTY_CASH)
 | 
			
		||||
        self.assertEqual(editor.credit.travel.tags[1].accounts[1].code,
 | 
			
		||||
                         Accounts.CASH)
 | 
			
		||||
 | 
			
		||||
@@ -152,7 +151,7 @@ class SummeryEditorTestCase(unittest.TestCase):
 | 
			
		||||
        self.assertEqual(editor.credit.bus.tags[0].accounts[0].code,
 | 
			
		||||
                         Accounts.PREPAID)
 | 
			
		||||
        self.assertEqual(editor.credit.bus.tags[0].accounts[1].code,
 | 
			
		||||
                         Accounts.PAYABLE)
 | 
			
		||||
                         Accounts.PETTY_CASH)
 | 
			
		||||
        self.assertEqual(editor.credit.bus.tags[1].name, "Bus")
 | 
			
		||||
        self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1)
 | 
			
		||||
        self.assertEqual(editor.credit.bus.tags[1].accounts[0].code,
 | 
			
		||||
@@ -186,7 +185,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
 | 
			
		||||
             "currency-0-debit-1-account_code": Accounts.MEAL,
 | 
			
		||||
             "currency-0-debit-1-summary": " Lunch—Fries ",
 | 
			
		||||
             "currency-0-debit-1-amount": "2.15",
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-1-summary": " Lunch—Fries ",
 | 
			
		||||
             "currency-0-credit-1-amount": "2.15",
 | 
			
		||||
             "currency-0-debit-2-account_code": Accounts.MEAL,
 | 
			
		||||
@@ -208,7 +207,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
 | 
			
		||||
             "currency-0-debit-1-account_code": Accounts.MEAL,
 | 
			
		||||
             "currency-0-debit-1-summary": " Dinner—Steak  ",
 | 
			
		||||
             "currency-0-debit-1-amount": "8.28",
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-1-summary": " Dinner—Steak ",
 | 
			
		||||
             "currency-0-credit-1-amount": "8.28"},
 | 
			
		||||
            {"csrf_token": csrf_token,
 | 
			
		||||
@@ -218,13 +217,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
 | 
			
		||||
             "currency-0-debit-0-account_code": Accounts.MEAL,
 | 
			
		||||
             "currency-0-debit-0-summary": " Lunch—Pizza  ",
 | 
			
		||||
             "currency-0-debit-0-amount": "5.49",
 | 
			
		||||
             "currency-0-credit-0-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-0-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-0-summary": " Lunch—Pizza ",
 | 
			
		||||
             "currency-0-credit-0-amount": "5.49",
 | 
			
		||||
             "currency-0-debit-1-account_code": Accounts.MEAL,
 | 
			
		||||
             "currency-0-debit-1-summary": " Lunch—Noodles ",
 | 
			
		||||
             "currency-0-debit-1-amount": "7.47",
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-1-summary": " Lunch—Noodles ",
 | 
			
		||||
             "currency-0-credit-1-amount": "7.47"},
 | 
			
		||||
            {"csrf_token": csrf_token,
 | 
			
		||||
@@ -259,7 +258,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
 | 
			
		||||
             "currency-0-debit-3-account_code": Accounts.TRAVEL,
 | 
			
		||||
             "currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
 | 
			
		||||
             "currency-0-debit-3-amount": "4.4",
 | 
			
		||||
             "currency-0-credit-3-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-3-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
 | 
			
		||||
             "currency-0-credit-3-amount": "4.4"},
 | 
			
		||||
            {"csrf_token": csrf_token,
 | 
			
		||||
@@ -275,31 +274,31 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
 | 
			
		||||
             "currency-0-debit-1-account_code": Accounts.TRAVEL,
 | 
			
		||||
             "currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
 | 
			
		||||
             "currency-0-debit-1-amount": "12",
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
 | 
			
		||||
             "currency-0-credit-1-amount": "12",
 | 
			
		||||
             "currency-0-debit-2-account_code": Accounts.TRAVEL,
 | 
			
		||||
             "currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
 | 
			
		||||
             "currency-0-debit-2-amount": "8",
 | 
			
		||||
             "currency-0-credit-2-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-2-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
 | 
			
		||||
             "currency-0-credit-2-amount": "8",
 | 
			
		||||
             "currency-0-debit-3-account_code": Accounts.TRAVEL,
 | 
			
		||||
             "currency-0-debit-3-summary": " Bike—City Hall→Office ",
 | 
			
		||||
             "currency-0-debit-3-amount": "3.5",
 | 
			
		||||
             "currency-0-credit-3-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-3-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-3-summary": " Bike—City Hall→Office ",
 | 
			
		||||
             "currency-0-credit-3-amount": "3.5",
 | 
			
		||||
             "currency-0-debit-4-account_code": Accounts.TRAVEL,
 | 
			
		||||
             "currency-0-debit-4-summary": " Bike—Restaurant→Office ",
 | 
			
		||||
             "currency-0-debit-4-amount": "4",
 | 
			
		||||
             "currency-0-credit-4-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-4-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-4-summary": " Bike—Restaurant→Office ",
 | 
			
		||||
             "currency-0-credit-4-amount": "4",
 | 
			
		||||
             "currency-0-debit-5-account_code": Accounts.TRAVEL,
 | 
			
		||||
             "currency-0-debit-5-summary": " Bike—Office→Theatre ",
 | 
			
		||||
             "currency-0-debit-5-amount": "1.5",
 | 
			
		||||
             "currency-0-credit-5-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-credit-5-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-credit-5-summary": " Bike—Office→Theatre ",
 | 
			
		||||
             "currency-0-credit-5-amount": "1.5",
 | 
			
		||||
             "currency-0-debit-6-account_code": Accounts.TRAVEL,
 | 
			
		||||
@@ -312,13 +311,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
 | 
			
		||||
             "next": NEXT_URI,
 | 
			
		||||
             "date": txn_date,
 | 
			
		||||
             "currency-0-code": "USD",
 | 
			
		||||
             "currency-0-debit-0-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-debit-0-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-debit-0-summary": " Dinner—Steak  ",
 | 
			
		||||
             "currency-0-debit-0-amount": "8.28",
 | 
			
		||||
             "currency-0-credit-0-account_code": Accounts.BANK,
 | 
			
		||||
             "currency-0-credit-0-summary": " Dinner—Steak ",
 | 
			
		||||
             "currency-0-credit-0-amount": "8.28",
 | 
			
		||||
             "currency-0-debit-1-account_code": Accounts.PAYABLE,
 | 
			
		||||
             "currency-0-debit-1-account_code": Accounts.PETTY_CASH,
 | 
			
		||||
             "currency-0-debit-1-summary": " Lunch—Pizza ",
 | 
			
		||||
             "currency-0-debit-1-amount": "5.49",
 | 
			
		||||
             "currency-0-credit-1-account_code": Accounts.BANK,
 | 
			
		||||
 
 | 
			
		||||
@@ -26,8 +26,8 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import create_app, db
 | 
			
		||||
from testlib import get_client
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client
 | 
			
		||||
from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \
 | 
			
		||||
    get_update_form, match_txn_detail, set_negative_amount, \
 | 
			
		||||
    remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \
 | 
			
		||||
@@ -48,7 +48,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -600,7 +600,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -1159,7 +1159,7 @@ class TransferTransactionTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -1973,7 +1973,7 @@ class TransactionReorderTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
 
 | 
			
		||||
@@ -21,13 +21,12 @@ import unittest
 | 
			
		||||
from urllib.parse import quote_plus
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask, request, render_template_string
 | 
			
		||||
from flask import Flask, request
 | 
			
		||||
 | 
			
		||||
from accounting.utils.next_uri import append_next, inherit_next, or_next
 | 
			
		||||
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
 | 
			
		||||
from accounting.utils.query import parse_query_keywords
 | 
			
		||||
from test_site import create_app
 | 
			
		||||
from testlib import TEST_SERVER
 | 
			
		||||
from testlib import TEST_SERVER, create_test_app, get_csrf_token
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NextUriTestCase(unittest.TestCase):
 | 
			
		||||
@@ -40,12 +39,7 @@ class NextUriTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
 | 
			
		||||
        @self.app.get("/test-csrf")
 | 
			
		||||
        def test_csrf() -> str:
 | 
			
		||||
            """The test view to return the CSRF token."""
 | 
			
		||||
            return render_template_string("{{csrf_token()}}")
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
 | 
			
		||||
    def test_next_uri(self) -> None:
 | 
			
		||||
        """Tests the next URI utilities with the next URI.
 | 
			
		||||
@@ -69,7 +63,7 @@ class NextUriTestCase(unittest.TestCase):
 | 
			
		||||
                              methods=["GET", "POST"])
 | 
			
		||||
        client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
 | 
			
		||||
        client.headers["Referer"] = TEST_SERVER
 | 
			
		||||
        csrf_token: str = client.get("/test-csrf").text
 | 
			
		||||
        csrf_token: str = get_csrf_token(client)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get("/test-next?next=/next&q=abc&page-no=4")
 | 
			
		||||
@@ -98,7 +92,7 @@ class NextUriTestCase(unittest.TestCase):
 | 
			
		||||
                              methods=["GET", "POST"])
 | 
			
		||||
        client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
 | 
			
		||||
        client.headers["Referer"] = TEST_SERVER
 | 
			
		||||
        csrf_token: str = client.get("/test-csrf").text
 | 
			
		||||
        csrf_token: str = get_csrf_token(client)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get("/test-no-next?q=abc&page-no=4")
 | 
			
		||||
@@ -171,7 +165,7 @@ class PaginationTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_app(is_testing=True)
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
        self.params = self.Params([], None, [], True)
 | 
			
		||||
 | 
			
		||||
        @self.app.get("/test-pagination")
 | 
			
		||||
 
 | 
			
		||||
@@ -18,15 +18,41 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from html.parser import HTMLParser
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask import Flask, render_template_string
 | 
			
		||||
 | 
			
		||||
from test_site import create_app
 | 
			
		||||
 | 
			
		||||
TEST_SERVER: str = "https://testserver"
 | 
			
		||||
"""The test server URI."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_test_app() -> Flask:
 | 
			
		||||
    """Creates and returns the testing Flask application.
 | 
			
		||||
 | 
			
		||||
    :return: The testing Flask application.
 | 
			
		||||
    """
 | 
			
		||||
    app: Flask = create_app(is_testing=True)
 | 
			
		||||
 | 
			
		||||
    @app.get("/.csrf-token")
 | 
			
		||||
    def get_csrf_token_view() -> str:
 | 
			
		||||
        """The test view to return the CSRF token."""
 | 
			
		||||
        return render_template_string("{{csrf_token()}}")
 | 
			
		||||
 | 
			
		||||
    return app
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_csrf_token(client: httpx.Client) -> str:
 | 
			
		||||
    """Returns the CSRF token.
 | 
			
		||||
 | 
			
		||||
    :param client: The httpx client.
 | 
			
		||||
    :return: The CSRF token.
 | 
			
		||||
    """
 | 
			
		||||
    return client.get("/.csrf-token").text
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
 | 
			
		||||
    """Returns a user client.
 | 
			
		||||
 | 
			
		||||
@@ -36,7 +62,7 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
 | 
			
		||||
    """
 | 
			
		||||
    client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
 | 
			
		||||
    client.headers["Referer"] = TEST_SERVER
 | 
			
		||||
    csrf_token: str = get_csrf_token(client, "/login")
 | 
			
		||||
    csrf_token: str = get_csrf_token(client)
 | 
			
		||||
    response: httpx.Response = client.post("/login",
 | 
			
		||||
                                           data={"csrf_token": csrf_token,
 | 
			
		||||
                                                 "username": username})
 | 
			
		||||
@@ -45,38 +71,6 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
 | 
			
		||||
    return client, csrf_token
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_csrf_token(client: httpx.Client, uri: str) -> str:
 | 
			
		||||
    """Returns the CSRF token from a form in a URI.
 | 
			
		||||
 | 
			
		||||
    :param client: The httpx client.
 | 
			
		||||
    :param uri: The URI.
 | 
			
		||||
    :return: The CSRF token.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class CsrfParser(HTMLParser):
 | 
			
		||||
        """The CSRF token parser."""
 | 
			
		||||
 | 
			
		||||
        def __init__(self):
 | 
			
		||||
            """Constructs the CSRF token parser."""
 | 
			
		||||
            super().__init__()
 | 
			
		||||
            self.csrf_token: str | None = None
 | 
			
		||||
            """The CSRF token."""
 | 
			
		||||
 | 
			
		||||
        def handle_starttag(self, tag: str,
 | 
			
		||||
                            attrs: list[tuple[str, str | None]]) -> None:
 | 
			
		||||
            """Handles when a start tag is found."""
 | 
			
		||||
            attrs_dict: dict[str, str] = dict(attrs)
 | 
			
		||||
            if attrs_dict.get("name") == "csrf_token":
 | 
			
		||||
                self.csrf_token = attrs_dict["value"]
 | 
			
		||||
 | 
			
		||||
    response: httpx.Response = client.get(uri)
 | 
			
		||||
    assert response.status_code == 200
 | 
			
		||||
    parser: CsrfParser = CsrfParser()
 | 
			
		||||
    parser.feed(response.text)
 | 
			
		||||
    assert parser.csrf_token is not None
 | 
			
		||||
    return parser.csrf_token
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_locale(client: httpx.Client, csrf_token: str,
 | 
			
		||||
               locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
 | 
			
		||||
    """Sets the current locale.
 | 
			
		||||
 
 | 
			
		||||
@@ -38,6 +38,7 @@ EMPTY_NOTE: str = " \n\n  "
 | 
			
		||||
class Accounts:
 | 
			
		||||
    """The shortcuts to the common accounts."""
 | 
			
		||||
    CASH: str = "1111-001"
 | 
			
		||||
    PETTY_CASH: str = "1112-001"
 | 
			
		||||
    BANK: str = "1113-001"
 | 
			
		||||
    PREPAID: str = "1258-001"
 | 
			
		||||
    PAYABLE: str = "2141-001"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user